dvarfe commited on
Commit
bb1589d
·
1 Parent(s): 7ee2717

style(dashboard): translate commentaries

Browse files
.gitignore CHANGED
@@ -1,2 +1,4 @@
1
  data/
2
- tests/
 
 
 
1
  data/
2
+ tests/
3
+ __pycache__/
4
+ .venv/
analysis/cache_utils.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Утилиты для кэширования и загрузки активаций SAE.
3
  """
4
 
5
  from pathlib import Path
@@ -17,20 +17,20 @@ from analysis.models import _iqa_activations
17
 
18
 
19
  def _df_mb(df: pd.DataFrame) -> float:
20
- """Объём памяти DataFrame в МБ."""
21
  return df.memory_usage(deep=True).sum() / 1024 ** 2
22
 
23
 
24
  def _sparse_mb(mat: sp.csr_matrix) -> float:
25
- """Объём памяти CSR-матрицы (данные + индексы) в МБ."""
26
  return (mat.data.nbytes + mat.indices.nbytes + mat.indptr.nbytes) / 1024 ** 2
27
 
28
 
29
  def _cache_paths(base_path: str) -> Tuple[str, str, str]:
30
  """
31
- Возвращает пути к трём файлам кэша из базового пути.
32
 
33
- Пример:
34
  'cache/kadid_acts.feather'
35
  → ('cache/kadid_acts_meta.feather', 'cache/kadid_acts_codes.npz',
36
  'cache/kadid_acts_steps.npz')
@@ -273,8 +273,8 @@ def collect_and_cache(
273
  sparse_mb = _sparse_mb(codes_csr)
274
  steps_mb = _sparse_mb(steps_csr)
275
 
276
- print(f'Activations: shape={codes_csr.shape}, {sparse_mb:.1f} МБ (sparse)')
277
- print(f'Activation steps: shape={steps_csr.shape}, {steps_mb:.1f} МБ (sparse)')
278
 
279
  meta_path, codes_path, steps_path = _cache_paths(output_path)
280
 
@@ -285,9 +285,9 @@ def collect_and_cache(
285
  print(f'Saved metadata ({len(meta_df)} rows) -> {meta_path}')
286
  print(f'Saved activations -> {codes_path}')
287
  print(f'Saved activation steps -> {steps_path}')
288
- print(f' Metadata: {_df_mb(meta_df):.1f} МБ')
289
- print(f' Activations: {sparse_mb:.1f} МБ')
290
- print(f' Steps: {steps_mb:.1f} МБ')
291
 
292
  if pristine_dataloader is not None:
293
  print('\nProcessing pristine dataset...')
@@ -318,8 +318,8 @@ def collect_and_cache(
318
  sp.save_npz(pristine_codes_path, pristine_codes)
319
  sp.save_npz(pristine_steps_path, pristine_steps)
320
 
321
- print(f'Pristine activations: shape={pristine_codes.shape}, {pristine_sparse_mb:.1f} МБ')
322
- print(f'Pristine steps: shape={pristine_steps.shape}, {pristine_steps_mb:.1f} МБ')
323
 
324
  print(f'Saved pristine metadata ({len(pristine_meta)} rows) -> {pristine_meta_path}')
325
  print(f'Saved pristine activations -> {pristine_codes_path}')
@@ -333,7 +333,7 @@ def build_activation_cache(
333
  dataset: str,
334
  cache_path: str,
335
  checkpoint_path: str,
336
- kadid_path: str,
337
  layer_num: int,
338
  iqa_metric: str,
339
  swin_num: int,
@@ -348,6 +348,7 @@ def build_activation_cache(
348
  add_patch_mask_stats: bool = True,
349
  include_pristine: bool = True,
350
  show_progress_bars: bool = True,
 
351
  ) -> Dict[str, Any]:
352
  """Build activation cache end-to-end for KADID/local-KADID datasets."""
353
  from .datasets import (
@@ -377,7 +378,7 @@ def build_activation_cache(
377
  label_to_dist_group = None
378
 
379
  if dataset == 'local_kadid':
380
- data = LocalKadidPresavedDataset(root=kadid_path, crop_size=crop_size)
381
  collate_fn = local_kadid_collate_fn
382
  meta_keys = [
383
  'dist_type',
@@ -392,11 +393,11 @@ def build_activation_cache(
392
  'distorted_img_path',
393
  'original_img_path',
394
  ]
395
- pristine_data = LocalKadidPristineDataset(root=kadid_path, crop_size=crop_size) if include_pristine else None
396
  pristine_collate = local_kadid_pristine_collate_fn
397
  elif dataset in {'kadid10k', 'kadid'}:
398
  data = Kadid10kDataset(
399
- root=kadid_path,
400
  crop_size=crop_size,
401
  min_distortion_level=min_distortion_level or 1,
402
  )
@@ -409,11 +410,11 @@ def build_activation_cache(
409
  'distorted_img_path',
410
  'original_img_path',
411
  ]
412
- pristine_data = KadidPristineDataset(root=kadid_path, crop_size=crop_size) if include_pristine else None
413
  pristine_collate = kadid_pristine_collate_fn
414
  elif dataset == 'QGround':
415
  data = QGroundDataset(
416
- root=kadid_path,
417
  split='test',
418
  crop_size=crop_size,
419
  )
@@ -438,9 +439,10 @@ def build_activation_cache(
438
  pristine_collate = None
439
  elif dataset == 'SRGround':
440
  data = SRGroundSmallDataset(
441
- root=kadid_path,
442
  json_path='srground_train.json',
443
  crop_size=crop_size,
 
444
  )
445
  collate_fn = srground_collate_fn
446
  meta_keys = [
@@ -538,11 +540,10 @@ def load_cache(
538
  Tuple[pd.DataFrame, sp.csr_matrix],
539
  Tuple[pd.DataFrame, sp.csr_matrix, sp.csr_matrix],
540
  ]:
541
- """Загружает кэш активаций SAE из раздельных файлов.
542
 
543
- Если ``return_activation_steps=True``, дополнительно возвращает CSR-матрицу
544
- порядка активаций, где значение ``0`` означает отсутствие активации,
545
- а ``k>0`` соответствует шагу ``k`` в pursuit.
546
  """
547
  meta_path, codes_path, steps_path = _cache_paths(path)
548
  meta = pd.read_feather(meta_path)
@@ -550,8 +551,8 @@ def load_cache(
550
 
551
  print(f'Loaded from {meta_path}: {meta.shape[0]} rows × {meta.shape[1]} cols')
552
  print(f'Loaded from {codes_path}: shape={codes.shape}, dtype={codes.dtype}')
553
- print(f' Metadata: {_df_mb(meta):.1f} МБ')
554
- print(f' Activations: {_sparse_mb(codes):.1f} МБ (sparse)')
555
 
556
  keep_idx: Optional[np.ndarray] = None
557
  if min_distortion_level is not None or max_distortion_level is not None:
@@ -569,7 +570,7 @@ def load_cache(
569
  keep_idx = np.flatnonzero(keep_mask.to_numpy())
570
  else:
571
  keep_mask = (meta['dist_level'] >= -1000)
572
- keep_idx = np.flatnonzero(keep_mask.to_numpy()) # Костыль -- поправить
573
 
574
  if return_activation_steps:
575
  if Path(steps_path).exists():
@@ -592,7 +593,7 @@ def load_cache(
592
  f'{meta.shape[0]} rows kept'
593
  )
594
 
595
- print(f' Steps: {_sparse_mb(steps):.1f} МБ (sparse)')
596
  return meta, codes, steps
597
 
598
  if keep_idx is not None:
@@ -620,8 +621,8 @@ def load_pristine_cache(
620
 
621
  print(f'Loaded pristine from {meta_path}: {meta.shape[0]} rows × {meta.shape[1]} cols')
622
  print(f'Loaded pristine from {codes_path}: shape={codes.shape}, dtype={codes.dtype}')
623
- print(f' Metadata: {_df_mb(meta):.1f} МБ')
624
- print(f' Activations: {_sparse_mb(codes):.1f} МБ (sparse)')
625
 
626
  if return_activation_steps:
627
  if Path(steps_path).exists():
@@ -635,7 +636,7 @@ def load_pristine_cache(
635
  print('No pristine steps cache found. Using all-zero activation steps.')
636
  steps = sp.csr_matrix(codes.shape, dtype=np.int32)
637
 
638
- print(f' Steps: {_sparse_mb(steps):.1f} МБ (sparse)')
639
  return meta, codes, steps
640
 
641
  return meta, codes
@@ -649,12 +650,12 @@ def ensure_cache_ready(
649
  load_cache_kwargs: Optional[Dict[str, Any]] = None,
650
  build_cache_fn: Optional[Callable[[], None]] = None,
651
  ) -> None:
652
- """Проверяет доступность кэша и при необходимости собирает его.
653
 
654
- Поведение:
655
- - пытается загрузить кэш через ``load_cache``;
656
- - если кэш отсутствует или выставлен ``force_recache=True``, запускает сборку;
657
- - если сборка отключена, пробрасывает ``FileNotFoundError``.
658
  """
659
  needs_rebuild = force_recache
660
  if not needs_rebuild:
@@ -688,18 +689,18 @@ def zero_codes_outside_activation_steps(
688
  activation_steps_csr: sp.csr_matrix,
689
  activation_steps_to_keep: List[int],
690
  ) -> sp.csr_matrix:
691
- """Обнуляет активации, шаг появления которых не входит в allow-list.
692
 
693
- Параметры
694
  ----------
695
- codes_csr : CSR-матрица активаций SAE.
696
- activation_steps_csr : CSR-матрица шагов активаций (0 = не активирован).
697
- activation_steps_to_keep : список шагов, которые нужно сохранить.
698
-
699
- Возвращает
700
- ----------
701
- CSR-матрицу той же формы, где вне указанных шагов значения занулены.
702
- Если список шагов пуст, возвращается исходная матрица без изменений.
703
  """
704
  if not activation_steps_to_keep:
705
  return codes_csr
@@ -739,7 +740,7 @@ def zero_codes_outside_activation_steps(
739
  def ensure_activation_cache(
740
  dataset: str,
741
  acts_cache_path: str,
742
- kadid_images_path: str,
743
  min_distortion_level: int,
744
  params: dict,
745
  include_pristine_cache: Optional[bool] = None,
@@ -769,7 +770,7 @@ def ensure_activation_cache(
769
  dataset=dataset,
770
  cache_path=acts_cache_path,
771
  checkpoint_path=params.get('SAE_CHECKPOINT_PATH'),
772
- kadid_path=kadid_images_path,
773
  layer_num=params.get('LAYER_NUM'),
774
  iqa_metric=params.get('IQA_METRIC'),
775
  swin_num=params.get('SWIN_NUM'),
@@ -783,5 +784,6 @@ def ensure_activation_cache(
783
  max_memory_gb=30.0,
784
  add_patch_mask_stats=True,
785
  include_pristine=needs_pristine_cache,
 
786
  )
787
  print('[run] Activation cache build completed.')
 
1
  """
2
+ Utilities for caching and loading SAE activations.
3
  """
4
 
5
  from pathlib import Path
 
17
 
18
 
19
  def _df_mb(df: pd.DataFrame) -> float:
20
+ """DataFrame memory usage in MB."""
21
  return df.memory_usage(deep=True).sum() / 1024 ** 2
22
 
23
 
24
  def _sparse_mb(mat: sp.csr_matrix) -> float:
25
+ """CSR matrix memory usage (data + indices) in MB."""
26
  return (mat.data.nbytes + mat.indices.nbytes + mat.indptr.nbytes) / 1024 ** 2
27
 
28
 
29
  def _cache_paths(base_path: str) -> Tuple[str, str, str]:
30
  """
31
+ Return paths to the three cache files derived from a base path.
32
 
33
+ Example:
34
  'cache/kadid_acts.feather'
35
  → ('cache/kadid_acts_meta.feather', 'cache/kadid_acts_codes.npz',
36
  'cache/kadid_acts_steps.npz')
 
273
  sparse_mb = _sparse_mb(codes_csr)
274
  steps_mb = _sparse_mb(steps_csr)
275
 
276
+ print(f'Activations: shape={codes_csr.shape}, {sparse_mb:.1f} MB (sparse)')
277
+ print(f'Activation steps: shape={steps_csr.shape}, {steps_mb:.1f} MB (sparse)')
278
 
279
  meta_path, codes_path, steps_path = _cache_paths(output_path)
280
 
 
285
  print(f'Saved metadata ({len(meta_df)} rows) -> {meta_path}')
286
  print(f'Saved activations -> {codes_path}')
287
  print(f'Saved activation steps -> {steps_path}')
288
+ print(f' Metadata: {_df_mb(meta_df):.1f} MB')
289
+ print(f' Activations: {sparse_mb:.1f} MB')
290
+ print(f' Steps: {steps_mb:.1f} MB')
291
 
292
  if pristine_dataloader is not None:
293
  print('\nProcessing pristine dataset...')
 
318
  sp.save_npz(pristine_codes_path, pristine_codes)
319
  sp.save_npz(pristine_steps_path, pristine_steps)
320
 
321
+ print(f'Pristine activations: shape={pristine_codes.shape}, {pristine_sparse_mb:.1f} MB')
322
+ print(f'Pristine steps: shape={pristine_steps.shape}, {pristine_steps_mb:.1f} MB')
323
 
324
  print(f'Saved pristine metadata ({len(pristine_meta)} rows) -> {pristine_meta_path}')
325
  print(f'Saved pristine activations -> {pristine_codes_path}')
 
333
  dataset: str,
334
  cache_path: str,
335
  checkpoint_path: str,
336
+ dataset_root: str,
337
  layer_num: int,
338
  iqa_metric: str,
339
  swin_num: int,
 
348
  add_patch_mask_stats: bool = True,
349
  include_pristine: bool = True,
350
  show_progress_bars: bool = True,
351
+ srground_include_sr_artifact: bool = False,
352
  ) -> Dict[str, Any]:
353
  """Build activation cache end-to-end for KADID/local-KADID datasets."""
354
  from .datasets import (
 
378
  label_to_dist_group = None
379
 
380
  if dataset == 'local_kadid':
381
+ data = LocalKadidPresavedDataset(root=dataset_root, crop_size=crop_size)
382
  collate_fn = local_kadid_collate_fn
383
  meta_keys = [
384
  'dist_type',
 
393
  'distorted_img_path',
394
  'original_img_path',
395
  ]
396
+ pristine_data = LocalKadidPristineDataset(root=dataset_root, crop_size=crop_size) if include_pristine else None
397
  pristine_collate = local_kadid_pristine_collate_fn
398
  elif dataset in {'kadid10k', 'kadid'}:
399
  data = Kadid10kDataset(
400
+ root=dataset_root,
401
  crop_size=crop_size,
402
  min_distortion_level=min_distortion_level or 1,
403
  )
 
410
  'distorted_img_path',
411
  'original_img_path',
412
  ]
413
+ pristine_data = KadidPristineDataset(root=dataset_root, crop_size=crop_size) if include_pristine else None
414
  pristine_collate = kadid_pristine_collate_fn
415
  elif dataset == 'QGround':
416
  data = QGroundDataset(
417
+ root=dataset_root,
418
  split='test',
419
  crop_size=crop_size,
420
  )
 
439
  pristine_collate = None
440
  elif dataset == 'SRGround':
441
  data = SRGroundSmallDataset(
442
+ root=dataset_root,
443
  json_path='srground_train.json',
444
  crop_size=crop_size,
445
+ include_sr_artifact=srground_include_sr_artifact,
446
  )
447
  collate_fn = srground_collate_fn
448
  meta_keys = [
 
540
  Tuple[pd.DataFrame, sp.csr_matrix],
541
  Tuple[pd.DataFrame, sp.csr_matrix, sp.csr_matrix],
542
  ]:
543
+ """Load SAE activation cache from separate files.
544
 
545
+ If ``return_activation_steps=True``, also returns a CSR matrix of activation
546
+ order, where ``0`` means no activation and ``k>0`` corresponds to pursuit step ``k``.
 
547
  """
548
  meta_path, codes_path, steps_path = _cache_paths(path)
549
  meta = pd.read_feather(meta_path)
 
551
 
552
  print(f'Loaded from {meta_path}: {meta.shape[0]} rows × {meta.shape[1]} cols')
553
  print(f'Loaded from {codes_path}: shape={codes.shape}, dtype={codes.dtype}')
554
+ print(f' Metadata: {_df_mb(meta):.1f} MB')
555
+ print(f' Activations: {_sparse_mb(codes):.1f} MB (sparse)')
556
 
557
  keep_idx: Optional[np.ndarray] = None
558
  if min_distortion_level is not None or max_distortion_level is not None:
 
570
  keep_idx = np.flatnonzero(keep_mask.to_numpy())
571
  else:
572
  keep_mask = (meta['dist_level'] >= -1000)
573
+ keep_idx = np.flatnonzero(keep_mask.to_numpy()) # Temporary workaround -- fix later
574
 
575
  if return_activation_steps:
576
  if Path(steps_path).exists():
 
593
  f'{meta.shape[0]} rows kept'
594
  )
595
 
596
+ print(f' Steps: {_sparse_mb(steps):.1f} MB (sparse)')
597
  return meta, codes, steps
598
 
599
  if keep_idx is not None:
 
621
 
622
  print(f'Loaded pristine from {meta_path}: {meta.shape[0]} rows × {meta.shape[1]} cols')
623
  print(f'Loaded pristine from {codes_path}: shape={codes.shape}, dtype={codes.dtype}')
624
+ print(f' Metadata: {_df_mb(meta):.1f} MB')
625
+ print(f' Activations: {_sparse_mb(codes):.1f} MB (sparse)')
626
 
627
  if return_activation_steps:
628
  if Path(steps_path).exists():
 
636
  print('No pristine steps cache found. Using all-zero activation steps.')
637
  steps = sp.csr_matrix(codes.shape, dtype=np.int32)
638
 
639
+ print(f' Steps: {_sparse_mb(steps):.1f} MB (sparse)')
640
  return meta, codes, steps
641
 
642
  return meta, codes
 
650
  load_cache_kwargs: Optional[Dict[str, Any]] = None,
651
  build_cache_fn: Optional[Callable[[], None]] = None,
652
  ) -> None:
653
+ """Check cache availability and rebuild it if needed.
654
 
655
+ Behavior:
656
+ - try loading the cache via ``load_cache``;
657
+ - if the cache is missing or ``force_recache=True``, start a rebuild;
658
+ - if building is disabled, raise ``FileNotFoundError``.
659
  """
660
  needs_rebuild = force_recache
661
  if not needs_rebuild:
 
689
  activation_steps_csr: sp.csr_matrix,
690
  activation_steps_to_keep: List[int],
691
  ) -> sp.csr_matrix:
692
+ """Zero out activations whose appearance step is not in the allow-list.
693
 
694
+ Parameters
695
  ----------
696
+ codes_csr : CSR matrix of SAE activations.
697
+ activation_steps_csr : CSR matrix of activation steps (0 = not activated).
698
+ activation_steps_to_keep : list of steps to keep.
699
+
700
+ Returns
701
+ -------
702
+ CSR matrix of the same shape with values outside the specified steps zeroed.
703
+ If the step list is empty, returns the original matrix unchanged.
704
  """
705
  if not activation_steps_to_keep:
706
  return codes_csr
 
740
  def ensure_activation_cache(
741
  dataset: str,
742
  acts_cache_path: str,
743
+ dataset_root: str,
744
  min_distortion_level: int,
745
  params: dict,
746
  include_pristine_cache: Optional[bool] = None,
 
770
  dataset=dataset,
771
  cache_path=acts_cache_path,
772
  checkpoint_path=params.get('SAE_CHECKPOINT_PATH'),
773
+ dataset_root=dataset_root,
774
  layer_num=params.get('LAYER_NUM'),
775
  iqa_metric=params.get('IQA_METRIC'),
776
  swin_num=params.get('SWIN_NUM'),
 
784
  max_memory_gb=30.0,
785
  add_patch_mask_stats=True,
786
  include_pristine=needs_pristine_cache,
787
+ srground_include_sr_artifact=bool(params.get('SRGROUND_INCLUDE_SR_ARTIFACT', False)),
788
  )
789
  print('[run] Activation cache build completed.')
analysis/config.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Конфигурация SAE-визуализаций.
3
  """
4
 
5
  from __future__ import annotations
@@ -12,14 +12,10 @@ from typing import Dict, List, Mapping, Sequence, Tuple
12
  DEFAULT_SAE_VIS_CONFIG_PATH = os.path.join(
13
  os.path.dirname(__file__), '..', 'default_configs', 'sae_vis_config.json'
14
  )
 
15
  SUPPORTED_DATASETS: Tuple[str, ...] = ('kadid10k', 'local_kadid', 'QGround', 'SRGround')
16
 
17
- _DATASET_IMAGE_SUBDIRS = {
18
- 'kadid10k': 'kadid10k',
19
- 'local_kadid': 'local_kadid',
20
- 'QGround': 'QGround',
21
- 'SRGround': 'SRGround',
22
- }
23
 
24
 
25
  def dataset_images_root(datasets_root: str, dataset: str) -> str:
@@ -74,6 +70,7 @@ class SaeVisConfig:
74
  DOWNSCALE_FACTOR: int
75
  KADID_MIN_DISTORTION_LEVEL: int
76
  KADID_MAX_DISTORTION_LEVEL: int
 
77
  SCALING_FACTOR: float
78
  BATCH_SIZE: int
79
  NUM_WORKERS: int
@@ -116,6 +113,7 @@ class SaeVisConfig:
116
  'CROP_SIZE': self.CROP_SIZE,
117
  'SCALING_FACTOR': self.SCALING_FACTOR,
118
  'KADID_MAX_DISTORTION_LEVEL': self.KADID_MAX_DISTORTION_LEVEL,
 
119
  }
120
 
121
 
@@ -256,10 +254,8 @@ def load_sae_vis_config(path: str | None = None) -> SaeVisConfig:
256
  raise ValueError('ACTIVATION_STEPS_TO_KEEP must contain only positive integers')
257
 
258
  dataset = str(_cfg_get(vis_cfg, 'DATASET', 'kadid10k')).strip()
259
- supported_raw = _cfg_get(vis_cfg, 'SUPPORTED_DATASETS', list(SUPPORTED_DATASETS))
260
- supported_datasets = tuple(str(value) for value in supported_raw)
261
- if dataset not in supported_datasets:
262
- raise ValueError(f'Unsupported DATASET={dataset!r}; expected one of {supported_datasets}')
263
 
264
  cache_dir = str(
265
  _cfg_get(
@@ -268,7 +264,7 @@ def load_sae_vis_config(path: str | None = None) -> SaeVisConfig:
268
  os.path.join(os.path.dirname(os.path.dirname(sae_checkpoint_path)), 'cache/'),
269
  )
270
  )
271
- dataset_cache_configs = build_dataset_cache_paths(cache_dir, supported_datasets)
272
 
273
  raw_selector_configs = _cfg_get(vis_cfg, 'SELECTOR_CONFIGS', [])
274
  if raw_selector_configs is None:
@@ -301,6 +297,7 @@ def load_sae_vis_config(path: str | None = None) -> SaeVisConfig:
301
  DOWNSCALE_FACTOR=int(_cfg_get(vis_cfg, 'DOWNSCALE_FACTOR', 2)),
302
  KADID_MIN_DISTORTION_LEVEL=kadid_min_distortion_level,
303
  KADID_MAX_DISTORTION_LEVEL=kadid_max_distortion_level,
 
304
  SCALING_FACTOR=float(_cfg_get(sae_cfg, 'scaling_factor', 1.0)),
305
  BATCH_SIZE=int(_cfg_get(vis_cfg, 'BATCH_SIZE', 32)),
306
  NUM_WORKERS=int(_cfg_get(vis_cfg, 'NUM_WORKERS', 4)),
@@ -324,7 +321,7 @@ def load_sae_vis_config(path: str | None = None) -> SaeVisConfig:
324
  SHOW_PROGRESS_BARS=bool(_cfg_get(vis_cfg, 'SHOW_PROGRESS_BARS', True)),
325
  CACHE_DIR=cache_dir,
326
  DATASET=dataset,
327
- SUPPORTED_DATASETS=supported_datasets,
328
  DATASET_CACHE_CONFIGS=dataset_cache_configs,
329
  CACHE_PATHS=dataset_cache_configs[dataset],
330
  KADID_IMAGES_PATH=os.path.join(datasets_root, dataset_images_subdir),
 
1
  """
2
+ SAE visualization configuration.
3
  """
4
 
5
  from __future__ import annotations
 
12
  DEFAULT_SAE_VIS_CONFIG_PATH = os.path.join(
13
  os.path.dirname(__file__), '..', 'default_configs', 'sae_vis_config.json'
14
  )
15
+
16
  SUPPORTED_DATASETS: Tuple[str, ...] = ('kadid10k', 'local_kadid', 'QGround', 'SRGround')
17
 
18
+ _DATASET_IMAGE_SUBDIRS = {dataset: dataset for dataset in SUPPORTED_DATASETS}
 
 
 
 
 
19
 
20
 
21
  def dataset_images_root(datasets_root: str, dataset: str) -> str:
 
70
  DOWNSCALE_FACTOR: int
71
  KADID_MIN_DISTORTION_LEVEL: int
72
  KADID_MAX_DISTORTION_LEVEL: int
73
+ SRGROUND_INCLUDE_SR_ARTIFACT: bool
74
  SCALING_FACTOR: float
75
  BATCH_SIZE: int
76
  NUM_WORKERS: int
 
113
  'CROP_SIZE': self.CROP_SIZE,
114
  'SCALING_FACTOR': self.SCALING_FACTOR,
115
  'KADID_MAX_DISTORTION_LEVEL': self.KADID_MAX_DISTORTION_LEVEL,
116
+ 'SRGROUND_INCLUDE_SR_ARTIFACT': self.SRGROUND_INCLUDE_SR_ARTIFACT,
117
  }
118
 
119
 
 
254
  raise ValueError('ACTIVATION_STEPS_TO_KEEP must contain only positive integers')
255
 
256
  dataset = str(_cfg_get(vis_cfg, 'DATASET', 'kadid10k')).strip()
257
+ if dataset not in SUPPORTED_DATASETS:
258
+ raise ValueError(f'Unsupported DATASET={dataset!r}; expected one of {SUPPORTED_DATASETS}')
 
 
259
 
260
  cache_dir = str(
261
  _cfg_get(
 
264
  os.path.join(os.path.dirname(os.path.dirname(sae_checkpoint_path)), 'cache/'),
265
  )
266
  )
267
+ dataset_cache_configs = build_dataset_cache_paths(cache_dir, SUPPORTED_DATASETS)
268
 
269
  raw_selector_configs = _cfg_get(vis_cfg, 'SELECTOR_CONFIGS', [])
270
  if raw_selector_configs is None:
 
297
  DOWNSCALE_FACTOR=int(_cfg_get(vis_cfg, 'DOWNSCALE_FACTOR', 2)),
298
  KADID_MIN_DISTORTION_LEVEL=kadid_min_distortion_level,
299
  KADID_MAX_DISTORTION_LEVEL=kadid_max_distortion_level,
300
+ SRGROUND_INCLUDE_SR_ARTIFACT=bool(_cfg_get(vis_cfg, 'SRGROUND_INCLUDE_SR_ARTIFACT', False)),
301
  SCALING_FACTOR=float(_cfg_get(sae_cfg, 'scaling_factor', 1.0)),
302
  BATCH_SIZE=int(_cfg_get(vis_cfg, 'BATCH_SIZE', 32)),
303
  NUM_WORKERS=int(_cfg_get(vis_cfg, 'NUM_WORKERS', 4)),
 
321
  SHOW_PROGRESS_BARS=bool(_cfg_get(vis_cfg, 'SHOW_PROGRESS_BARS', True)),
322
  CACHE_DIR=cache_dir,
323
  DATASET=dataset,
324
+ SUPPORTED_DATASETS=SUPPORTED_DATASETS,
325
  DATASET_CACHE_CONFIGS=dataset_cache_configs,
326
  CACHE_PATHS=dataset_cache_configs[dataset],
327
  KADID_IMAGES_PATH=os.path.join(datasets_root, dataset_images_subdir),
analysis/datasets.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Датасеты и вспомогательные структуры данных для KADID-10k. Взяты из репозитория PatchSAE.
3
  """
4
 
5
  import gzip
@@ -186,7 +186,20 @@ def _sr_artifact_labels(
186
  return np.where(sr_maps * sr_prom >= threshold, 6, 0).astype(np.uint8)
187
 
188
 
189
- def merge_srground_masks(annot_rd: np.ndarray, annot_sr: np.ndarray) -> np.ndarray:
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  return np.where(annot_sr == 6, 6, annot_rd).astype(np.uint8)
191
 
192
 
@@ -319,55 +332,114 @@ def srground_rows_for_image_paths(
319
  def srground_prominences_by_image_paths(
320
  image_paths: Sequence[str],
321
  *,
 
322
  datasets_root: str | None = None,
323
  ) -> dict[str, np.ndarray]:
324
  """Look up prominences for paths (absolute or SRGround-relative) via cached index."""
325
  if not image_paths:
326
  return {}
327
 
 
 
328
  root = _resolve_datasets_root(datasets_root)
329
  index = srground_prominences_index(root)
330
  keys = {srground_image_key(path, datasets_root=root) for path in image_paths if path}
331
  return {key: index[key] for key in keys if key in index}
332
 
333
 
334
- def srground_mask_rgb_for_meta_row(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  meta_row: pd.Series | None,
336
  *,
337
- datasets_root: str | None = None,
338
- include_sr_artifact: bool = True,
339
- crop_size: int = 224,
340
- sr_artifact_threshold: float = SRGROUND_SR_ARTIFACT_THRESHOLD,
341
- prominences: np.ndarray | None = None,
342
  ) -> np.ndarray | None:
343
- """Build an RGB annotation mask for one SRGround image (matches dashboard center crop)."""
344
  if meta_row is None:
345
  return None
346
 
347
- root = _resolve_datasets_root(datasets_root)
 
 
348
 
349
- def _row_path(column: str) -> str | None:
350
- if column not in meta_row:
351
- return None
352
- value = meta_row.get(column)
353
- if value is None or (isinstance(value, float) and np.isnan(value)):
354
- return None
355
- text = str(value).strip()
356
- return text or None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
 
358
  image_rel = _image_rel_from_meta_row(meta_row)
359
- real_rel = _row_path('real_distortions_ann_path')
360
- sr_rel = _row_path('sr_artifacts_ann_path')
361
- if not real_rel and not (include_sr_artifact and sr_rel):
362
  return None
363
 
364
  if prominences is None and image_rel:
365
- prominences = srground_prominences_by_image_paths([image_rel], datasets_root=root).get(image_rel)
 
 
 
366
 
367
  annot_rd = None
368
  annot_sr = None
369
  if real_rel:
370
- real_path = resolve_dataset_image_path('SRGround', real_rel, datasets_root=root)
371
  if real_path.is_file():
372
  real_maps = _load_npy_gz(real_path)
373
  prom = prominences
@@ -375,8 +447,8 @@ def srground_mask_rgb_for_meta_row(
375
  prom = np.ones(int(real_maps.shape[0]) + 1, dtype=np.float64)
376
  annot_rd = _real_distortion_labels(real_maps, prom)
377
 
378
- if include_sr_artifact and sr_rel:
379
- sr_path = resolve_dataset_image_path('SRGround', sr_rel, datasets_root=root)
380
  if sr_path.is_file():
381
  sr_maps = _load_npy_gz(sr_path)
382
  prom = prominences
@@ -391,25 +463,97 @@ def srground_mask_rgb_for_meta_row(
391
  if annot_rd is None and annot_sr is None:
392
  return None
393
 
394
- if annot_rd is None:
395
- annot_rd = np.zeros_like(annot_sr, dtype=np.uint8)
396
- if include_sr_artifact and annot_sr is not None:
397
- merged = merge_srground_masks(annot_rd, annot_sr)
398
- else:
399
- merged = annot_rd.astype(np.uint8, copy=False)
400
-
401
  if image_rel:
402
- image_path = resolve_dataset_image_path('SRGround', image_rel, datasets_root=root)
403
  if image_path.is_file():
404
  with Image.open(image_path) as image:
405
  reference_size = image.size
406
- merged = _center_crop_label_map(merged, crop_size, reference_size)
407
- else:
408
- merged = _center_crop_label_map(merged, crop_size, (merged.shape[1], merged.shape[0]))
409
- else:
410
- merged = _center_crop_label_map(merged, crop_size, (merged.shape[1], merged.shape[0]))
411
 
412
- return label2rgb_srground(merged)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
 
414
 
415
  def _rgb2label_qground(mask_rgb: np.ndarray) -> np.ndarray:
@@ -443,7 +587,7 @@ def _to_relative_dataset_path(path: Path, root: Path) -> str:
443
 
444
  class Kadid10kDataset(Dataset):
445
  """
446
- KADID-10k dataset. При семплинге применяется RandomCrop.
447
  """
448
 
449
  def __init__(
@@ -595,11 +739,11 @@ class LocalKadidPresavedDataset(Dataset):
595
 
596
  def kadid_collate_fn(batch: List[dict]) -> dict:
597
  """
598
- Collate для Kadid10kDataset.
599
 
600
- Возвращает:
601
  images: Tensor (B, C, H, W)
602
- + все остальные ключи как списки длины B
603
  """
604
  images = torch.stack([item["img"] for item in batch], dim=0)
605
  collated: dict = {"images": images}
@@ -799,10 +943,12 @@ class SRGroundSmallDataset(Dataset):
799
  allowed_methods: Optional[List[str]] = ['DiT4SR_x2'],
800
  crop_size: Optional[int] = None,
801
  sr_artifact_threshold: float = SRGROUND_SR_ARTIFACT_THRESHOLD,
 
802
  transform=None,
803
  ):
804
  self.root = Path(root)
805
  self.sr_artifact_threshold = float(sr_artifact_threshold)
 
806
  self.json_path = self.root / 'srground_train.json'
807
  df = pd.read_json(self.json_path)
808
 
@@ -845,10 +991,15 @@ class SRGroundSmallDataset(Dataset):
845
  if real_ann_path.exists():
846
  annot_rd = _real_distortion_labels(_load_npy_gz(real_ann_path), prominences)
847
 
848
- sr_path = sample.get('sr_artifacts_ann_path')
849
- sr_ann_path = self._resolve_path(sr_path)
850
- if sr_ann_path.exists():
851
- annot_sr = _sr_artifact_labels(_load_npy_gz(sr_ann_path), prominences, threshold=self.sr_artifact_threshold)
 
 
 
 
 
852
 
853
  if annot_rd is None and annot_sr is None:
854
  return None, None
@@ -883,15 +1034,31 @@ class SRGroundSmallDataset(Dataset):
883
  annot_sr = np.asarray(self.crop(mask_image), dtype=np.uint8)
884
 
885
  img_tensor = self.image_to_tensor(image)
 
886
 
887
- mask_rd = torch.from_numpy(annot_rd.astype(np.float32)).unsqueeze(0)
888
- mask_sr = torch.from_numpy(annot_sr.astype(np.float32)).unsqueeze(0)
889
-
890
- mask = torch.from_numpy(merge_srground_masks(annot_rd, annot_sr).astype(np.float32)).unsqueeze(0)
 
 
 
 
 
 
 
 
 
 
 
891
  mask_coverage = float((mask > 0).float().mean().item())
892
 
893
  real_ann_path = self._resolve_path(sample.get('real_distortions_ann_path'))
894
- sr_ann_path = self._resolve_path(sample.get('sr_artifacts_ann_path'))
 
 
 
 
895
 
896
  return {
897
  'img': img_tensor,
@@ -903,7 +1070,9 @@ class SRGroundSmallDataset(Dataset):
903
  'mask_coverage': mask_coverage,
904
  'prominences': sample.get('prominences'),
905
  'has_markup': sample.get('has_markup', False),
906
- 'sr_artifacts_ann_path': _to_relative_dataset_path(sr_ann_path, self.root),
 
 
907
  'real_distortions_ann_path': _to_relative_dataset_path(real_ann_path, self.root),
908
  'sample_id': sample.get('sample_id'),
909
  'distorted_img_path': _to_relative_dataset_path(img_path, self.root),
@@ -936,8 +1105,8 @@ def srground_collate_fn(batch: List[dict]) -> dict:
936
 
937
  class KadidPristineDataset(Dataset):
938
  """
939
- KADID-10k pristine (reference) images dataset.
940
- Возвращает только оригинальные изображения без искажений с RandomCrop.
941
  """
942
 
943
  def __init__(
@@ -957,7 +1126,7 @@ class KadidPristineDataset(Dataset):
957
  else:
958
  self.transform = transform
959
 
960
- # Находим все оригинальные изображения (формат I{number}.png без суффиксов)
961
  images_dir = self.root / "images"
962
  pristine_pattern = re.compile(r'^I\d+\.png$')
963
 
@@ -983,13 +1152,13 @@ class KadidPristineDataset(Dataset):
983
 
984
  return {
985
  "img": img,
986
- "mos": 5.0, # максимальное качество для pristine
987
  "dist_type": "pristine",
988
  "dist_group": "pristine",
989
- "dist_level": 0, # уровень искажений = 0
990
  "distorted_img_path": img_rel_path,
991
- "original_img_path": img_rel_path, # сам себе референс
992
- "sample_id": img_path.stem, # например "I01"
993
  }
994
 
995
 
@@ -997,9 +1166,9 @@ class LocalKadidPristineDataset(Dataset):
997
  """
998
  Pristine KADID dataset.
999
 
1000
- Ожидает корневую директорию с index.csv.
1001
- Обязательные колонки: original_img_path
1002
- Опциональные: mask_path (если есть маска области без искажений)
1003
  """
1004
 
1005
  def __init__(
@@ -1058,11 +1227,11 @@ class LocalKadidPristineDataset(Dataset):
1058
 
1059
  def kadid_pristine_collate_fn(batch: List[dict]) -> dict:
1060
  """
1061
- Collate для KadidPristineDataset.
1062
 
1063
- Возвращает:
1064
  images: Tensor (B, C, H, W)
1065
- + все остальные ключи как списки длины B
1066
  """
1067
  images = torch.stack([item["img"] for item in batch], dim=0)
1068
  collated: dict = {"images": images}
@@ -1077,18 +1246,18 @@ def kadid_pristine_collate_fn(batch: List[dict]) -> dict:
1077
 
1078
  def local_kadid_pristine_collate_fn(batch: List[dict]) -> dict:
1079
  """
1080
- Collate для LocalKadidPristinePresavedDataset.
1081
 
1082
- Возвращает:
1083
  images: Tensor (B, C, H, W)
1084
- masks: Tensor (B, 1, H, W) если has_masks=True
1085
- + остальные ключи как списки длины B
1086
  """
1087
  images = torch.stack([item["img"] for item in batch], dim=0)
1088
 
1089
  collated: dict = {"images": images}
1090
 
1091
- # Если есть маски в батче
1092
  if "mask" in batch[0]:
1093
  masks = torch.stack([item["mask"] for item in batch], dim=0)
1094
  collated["masks"] = masks
@@ -1116,33 +1285,40 @@ def resolve_dataset_image_path(
1116
  )
1117
  dataset_root = Path(dataset_images_root(str(root), dataset))
1118
  path = Path(path_from_meta)
1119
- return path if path.is_absolute() else (dataset_root / path)
 
 
 
 
 
 
 
1120
 
1121
 
1122
  def dataset_image_paths(
1123
  dataset: str,
1124
- kadid_images_path: str,
1125
  crop_size: int,
1126
  min_distortion_level: int,
1127
  ) -> List[str]:
1128
  if dataset == 'kadid10k':
1129
  ds = Kadid10kDataset(
1130
- kadid_images_path,
1131
  crop_size=crop_size,
1132
  min_distortion_level=min_distortion_level,
1133
  )
1134
  return [_to_relative_dataset_path(Path(p), ds.root) for p in ds.images]
1135
 
1136
  if dataset == 'local_kadid':
1137
- ds = LocalKadidPresavedDataset(kadid_images_path, crop_size=crop_size)
1138
  return [_to_relative_dataset_path(Path(str(p)), ds.root) for p in ds.df['distorted_img_path'].tolist()]
1139
 
1140
  if dataset == 'QGround':
1141
- ds = QGroundDataset(kadid_images_path, split='test', crop_size=crop_size)
1142
  return [str(sample['image_rel']) for sample in ds.samples]
1143
 
1144
  if dataset == 'SRGround':
1145
- ds = SRGroundSmallDataset(kadid_images_path, json_path='srground_train.json', allowed_methods=['DiT4SR_x2'])
1146
  return [_to_relative_dataset_path(Path(str(p)), ds.root) for p in ds.df['image_path'].tolist()]
1147
 
1148
  raise ValueError(f'Unsupported dataset: {dataset}')
 
1
  """
2
+ Datasets and helper data structures for KADID-10k. Taken from the PatchSAE repository.
3
  """
4
 
5
  import gzip
 
186
  return np.where(sr_maps * sr_prom >= threshold, 6, 0).astype(np.uint8)
187
 
188
 
189
+ def merge_srground_masks(
190
+ annot_rd: np.ndarray | None,
191
+ annot_sr: np.ndarray | None,
192
+ ) -> np.ndarray:
193
+ """Merge real-distortion and SR-artifact label maps (0=background, 1..6=classes).
194
+
195
+ If ``annot_sr`` is None, returns ``annot_rd`` unchanged (no SR merge).
196
+ """
197
+ if annot_sr is None:
198
+ if annot_rd is None:
199
+ raise ValueError('merge_srground_masks requires annot_rd when annot_sr is None')
200
+ return annot_rd.astype(np.uint8, copy=False)
201
+ if annot_rd is None:
202
+ return annot_sr.astype(np.uint8, copy=False)
203
  return np.where(annot_sr == 6, 6, annot_rd).astype(np.uint8)
204
 
205
 
 
332
  def srground_prominences_by_image_paths(
333
  image_paths: Sequence[str],
334
  *,
335
+ dataset_root: str | Path | None = None,
336
  datasets_root: str | None = None,
337
  ) -> dict[str, np.ndarray]:
338
  """Look up prominences for paths (absolute or SRGround-relative) via cached index."""
339
  if not image_paths:
340
  return {}
341
 
342
+ if datasets_root is None and dataset_root is not None:
343
+ datasets_root = parent_datasets_root(dataset_root)
344
  root = _resolve_datasets_root(datasets_root)
345
  index = srground_prominences_index(root)
346
  keys = {srground_image_key(path, datasets_root=root) for path in image_paths if path}
347
  return {key: index[key] for key in keys if key in index}
348
 
349
 
350
+ def _meta_row_path(meta_row: pd.Series, column: str) -> str | None:
351
+ if column not in meta_row:
352
+ return None
353
+ value = meta_row.get(column)
354
+ if value is None or (isinstance(value, float) and np.isnan(value)):
355
+ return None
356
+ text = str(value).strip()
357
+ return text or None
358
+
359
+
360
+ def parent_datasets_root(dataset_root: str | Path) -> str:
361
+ """Parent directory that contains dataset folders (e.g. ``Kadid`` for ``Kadid/SRGround``)."""
362
+ return str(Path(dataset_root).resolve().parent)
363
+
364
+
365
+ def _resolve_dataset_file_path(rel_path: str, *, dataset_root: str | Path) -> Path:
366
+ path = Path(str(rel_path).strip())
367
+ if path.is_absolute():
368
+ return path
369
+ return Path(dataset_root) / path
370
+
371
+
372
+ def infer_spatial_mask_dataset(meta_row: pd.Series | None) -> str | None:
373
+ """Return ``QGround`` or ``SRGround`` when meta row carries spatial mask fields."""
374
+ if meta_row is None:
375
+ return None
376
+ if _meta_row_path(meta_row, 'real_distortions_ann_path') or _meta_row_path(meta_row, 'sr_artifacts_ann_path'):
377
+ return 'SRGround'
378
+ if _meta_row_path(meta_row, 'mask_path'):
379
+ return 'QGround'
380
+ return None
381
+
382
+
383
+ def qground_mask_rgb_for_meta_row(
384
  meta_row: pd.Series | None,
385
  *,
386
+ dataset_root: str | Path,
 
 
 
 
387
  ) -> np.ndarray | None:
388
+ """Load QGround RGB segmentation mask PNG referenced from cache metadata."""
389
  if meta_row is None:
390
  return None
391
 
392
+ mask_rel = _meta_row_path(meta_row, 'mask_path')
393
+ if not mask_rel:
394
+ return None
395
 
396
+ mask_path = _resolve_dataset_file_path(mask_rel, dataset_root=dataset_root)
397
+ if not mask_path.is_file():
398
+ return None
399
+ return np.asarray(Image.open(mask_path).convert('RGB'), dtype=np.uint8)
400
+
401
+
402
+ def _resize_label_map_to_image(
403
+ mask_label: np.ndarray | None,
404
+ reference_size: tuple[int, int],
405
+ ) -> np.ndarray | None:
406
+ if mask_label is None:
407
+ return None
408
+ width, height = reference_size
409
+ if mask_label.shape[:2] == (height, width):
410
+ return mask_label.astype(np.uint8, copy=False)
411
+ mask_image = Image.fromarray(mask_label.astype(np.uint8), mode='L')
412
+ mask_image = mask_image.resize((width, height), resample=Image.NEAREST)
413
+ return np.asarray(mask_image, dtype=np.uint8)
414
+
415
+
416
+ def srground_label_map_for_meta_row(
417
+ meta_row: pd.Series | None,
418
+ *,
419
+ dataset_root: str | Path,
420
+ sr_artifact_threshold: float = SRGROUND_SR_ARTIFACT_THRESHOLD,
421
+ prominences: np.ndarray | None = None,
422
+ ) -> np.ndarray | None:
423
+ """Build a full-frame SRGround label map from paths stored in cache metadata."""
424
+ if meta_row is None:
425
+ return None
426
 
427
  image_rel = _image_rel_from_meta_row(meta_row)
428
+ real_rel = _meta_row_path(meta_row, 'real_distortions_ann_path')
429
+ sr_rel = _meta_row_path(meta_row, 'sr_artifacts_ann_path')
430
+ if not real_rel and not sr_rel:
431
  return None
432
 
433
  if prominences is None and image_rel:
434
+ prominences = srground_prominences_by_image_paths(
435
+ [image_rel],
436
+ dataset_root=dataset_root,
437
+ ).get(image_rel)
438
 
439
  annot_rd = None
440
  annot_sr = None
441
  if real_rel:
442
+ real_path = _resolve_dataset_file_path(real_rel, dataset_root=dataset_root)
443
  if real_path.is_file():
444
  real_maps = _load_npy_gz(real_path)
445
  prom = prominences
 
447
  prom = np.ones(int(real_maps.shape[0]) + 1, dtype=np.float64)
448
  annot_rd = _real_distortion_labels(real_maps, prom)
449
 
450
+ if sr_rel:
451
+ sr_path = _resolve_dataset_file_path(sr_rel, dataset_root=dataset_root)
452
  if sr_path.is_file():
453
  sr_maps = _load_npy_gz(sr_path)
454
  prom = prominences
 
463
  if annot_rd is None and annot_sr is None:
464
  return None
465
 
466
+ reference_size = (annot_rd if annot_rd is not None else annot_sr).shape[1], (
467
+ annot_rd if annot_rd is not None else annot_sr
468
+ ).shape[0]
 
 
 
 
469
  if image_rel:
470
+ image_path = _resolve_dataset_file_path(image_rel, dataset_root=dataset_root)
471
  if image_path.is_file():
472
  with Image.open(image_path) as image:
473
  reference_size = image.size
 
 
 
 
 
474
 
475
+ annot_rd = _resize_label_map_to_image(annot_rd, reference_size)
476
+ annot_sr = _resize_label_map_to_image(annot_sr, reference_size)
477
+ return merge_srground_masks(annot_rd, annot_sr)
478
+
479
+
480
+ def annotation_mask_rgb_for_meta_row(
481
+ meta_row: pd.Series | None,
482
+ *,
483
+ dataset_root: str | Path,
484
+ dataset: str | None = None,
485
+ crop_size: int = 512,
486
+ full_frame: bool = True,
487
+ sr_artifact_threshold: float = SRGROUND_SR_ARTIFACT_THRESHOLD,
488
+ prominences: np.ndarray | None = None,
489
+ ) -> np.ndarray | None:
490
+ """RGB annotation mask for QGround / SRGround visualization (QGround-style full frame by default)."""
491
+
492
+ if dataset_root is None:
493
+ from analysis.config import load_sae_vis_config
494
+ cfg = load_sae_vis_config()
495
+ datasets_root = str(cfg.DATASETS_ROOT)
496
+ dataset_root = Path(datasets_root) / dataset
497
+
498
+ dataset_name = dataset or infer_spatial_mask_dataset(meta_row)
499
+ if dataset_name == 'QGround':
500
+ return qground_mask_rgb_for_meta_row(meta_row, dataset_root=dataset_root)
501
+ if dataset_name == 'SRGround':
502
+ label_map = srground_label_map_for_meta_row(
503
+ meta_row,
504
+ dataset_root=dataset_root,
505
+ sr_artifact_threshold=sr_artifact_threshold,
506
+ prominences=prominences,
507
+ )
508
+ if label_map is None:
509
+ return None
510
+ if not full_frame:
511
+ image_rel = _image_rel_from_meta_row(meta_row)
512
+ reference_size = (label_map.shape[1], label_map.shape[0])
513
+ if image_rel:
514
+ image_path = _resolve_dataset_file_path(image_rel, dataset_root=dataset_root)
515
+ if image_path.is_file():
516
+ with Image.open(image_path) as image:
517
+ reference_size = image.size
518
+ label_map = _center_crop_label_map(label_map, crop_size, reference_size)
519
+ return label2rgb_srground(label_map)
520
+ return None
521
+
522
+
523
+ def get_srground_rgb_mask(
524
+ meta_row: pd.Series | None,
525
+ *,
526
+ dataset_root: str | Path,
527
+ crop_size: int = 224,
528
+ full_frame: bool = True,
529
+ sr_artifact_threshold: float = SRGROUND_SR_ARTIFACT_THRESHOLD,
530
+ ) -> np.ndarray | None:
531
+ """RGB SRGround mask for one cache meta row (prominences from ``srground_train.json``)."""
532
+ return annotation_mask_rgb_for_meta_row(
533
+ meta_row,
534
+ dataset_root=dataset_root,
535
+ dataset='SRGround',
536
+ crop_size=crop_size,
537
+ full_frame=full_frame,
538
+ sr_artifact_threshold=sr_artifact_threshold,
539
+ )
540
+
541
+
542
+ def srground_mask_rgb_for_meta_row(
543
+ meta_row: pd.Series | None,
544
+ *,
545
+ dataset_root: str | Path,
546
+ crop_size: int = 224,
547
+ sr_artifact_threshold: float = SRGROUND_SR_ARTIFACT_THRESHOLD,
548
+ ) -> np.ndarray | None:
549
+ """Center-cropped SRGround mask preview (alias of :func:`get_srground_rgb_mask`)."""
550
+ return get_srground_rgb_mask(
551
+ meta_row,
552
+ dataset_root=dataset_root,
553
+ crop_size=crop_size,
554
+ full_frame=False,
555
+ sr_artifact_threshold=sr_artifact_threshold,
556
+ )
557
 
558
 
559
  def _rgb2label_qground(mask_rgb: np.ndarray) -> np.ndarray:
 
587
 
588
  class Kadid10kDataset(Dataset):
589
  """
590
+ KADID-10k dataset. RandomCrop is applied during sampling.
591
  """
592
 
593
  def __init__(
 
739
 
740
  def kadid_collate_fn(batch: List[dict]) -> dict:
741
  """
742
+ Collate for Kadid10kDataset.
743
 
744
+ Returns:
745
  images: Tensor (B, C, H, W)
746
+ + all other keys as lists of length B
747
  """
748
  images = torch.stack([item["img"] for item in batch], dim=0)
749
  collated: dict = {"images": images}
 
943
  allowed_methods: Optional[List[str]] = ['DiT4SR_x2'],
944
  crop_size: Optional[int] = None,
945
  sr_artifact_threshold: float = SRGROUND_SR_ARTIFACT_THRESHOLD,
946
+ include_sr_artifact: bool = False,
947
  transform=None,
948
  ):
949
  self.root = Path(root)
950
  self.sr_artifact_threshold = float(sr_artifact_threshold)
951
+ self.include_sr_artifact = bool(include_sr_artifact)
952
  self.json_path = self.root / 'srground_train.json'
953
  df = pd.read_json(self.json_path)
954
 
 
991
  if real_ann_path.exists():
992
  annot_rd = _real_distortion_labels(_load_npy_gz(real_ann_path), prominences)
993
 
994
+ if self.include_sr_artifact:
995
+ sr_path = sample.get('sr_artifacts_ann_path')
996
+ sr_ann_path = self._resolve_path(sr_path)
997
+ if sr_ann_path.exists():
998
+ annot_sr = _sr_artifact_labels(
999
+ _load_npy_gz(sr_ann_path),
1000
+ prominences,
1001
+ threshold=self.sr_artifact_threshold,
1002
+ )
1003
 
1004
  if annot_rd is None and annot_sr is None:
1005
  return None, None
 
1034
  annot_sr = np.asarray(self.crop(mask_image), dtype=np.uint8)
1035
 
1036
  img_tensor = self.image_to_tensor(image)
1037
+ mask_hw = (image.height, image.width)
1038
 
1039
+ if annot_rd is None and annot_sr is None:
1040
+ mask_label = np.zeros(mask_hw, dtype=np.uint8)
1041
+ else:
1042
+ mask_label = merge_srground_masks(annot_rd, annot_sr)
1043
+ mask = torch.from_numpy(mask_label.astype(np.float32)).unsqueeze(0)
1044
+ mask_rd = (
1045
+ torch.from_numpy(annot_rd.astype(np.float32)).unsqueeze(0)
1046
+ if annot_rd is not None
1047
+ else torch.zeros((1, *mask_hw), dtype=torch.float32)
1048
+ )
1049
+ mask_sr = (
1050
+ torch.from_numpy(annot_sr.astype(np.float32)).unsqueeze(0)
1051
+ if annot_sr is not None
1052
+ else torch.zeros((1, *mask_hw), dtype=torch.float32)
1053
+ )
1054
  mask_coverage = float((mask > 0).float().mean().item())
1055
 
1056
  real_ann_path = self._resolve_path(sample.get('real_distortions_ann_path'))
1057
+ sr_ann_path = None
1058
+ if self.include_sr_artifact:
1059
+ candidate = self._resolve_path(sample.get('sr_artifacts_ann_path'))
1060
+ if candidate.exists():
1061
+ sr_ann_path = candidate
1062
 
1063
  return {
1064
  'img': img_tensor,
 
1070
  'mask_coverage': mask_coverage,
1071
  'prominences': sample.get('prominences'),
1072
  'has_markup': sample.get('has_markup', False),
1073
+ 'sr_artifacts_ann_path': (
1074
+ _to_relative_dataset_path(sr_ann_path, self.root) if sr_ann_path is not None else None
1075
+ ),
1076
  'real_distortions_ann_path': _to_relative_dataset_path(real_ann_path, self.root),
1077
  'sample_id': sample.get('sample_id'),
1078
  'distorted_img_path': _to_relative_dataset_path(img_path, self.root),
 
1105
 
1106
  class KadidPristineDataset(Dataset):
1107
  """
1108
+ KADID-10k pristine (reference) images dataset.
1109
+ Returns only original undistorted images with RandomCrop.
1110
  """
1111
 
1112
  def __init__(
 
1126
  else:
1127
  self.transform = transform
1128
 
1129
+ # Find all original images (I{number}.png format without suffixes)
1130
  images_dir = self.root / "images"
1131
  pristine_pattern = re.compile(r'^I\d+\.png$')
1132
 
 
1152
 
1153
  return {
1154
  "img": img,
1155
+ "mos": 5.0, # maximum quality for pristine images
1156
  "dist_type": "pristine",
1157
  "dist_group": "pristine",
1158
+ "dist_level": 0, # distortion level = 0
1159
  "distorted_img_path": img_rel_path,
1160
+ "original_img_path": img_rel_path, # self-reference
1161
+ "sample_id": img_path.stem, # e.g. "I01"
1162
  }
1163
 
1164
 
 
1166
  """
1167
  Pristine KADID dataset.
1168
 
1169
+ Expects a root directory with index.csv.
1170
+ Required columns: original_img_path
1171
+ Optional: mask_path (if a distortion-free region mask is available)
1172
  """
1173
 
1174
  def __init__(
 
1227
 
1228
  def kadid_pristine_collate_fn(batch: List[dict]) -> dict:
1229
  """
1230
+ Collate for KadidPristineDataset.
1231
 
1232
+ Returns:
1233
  images: Tensor (B, C, H, W)
1234
+ + all other keys as lists of length B
1235
  """
1236
  images = torch.stack([item["img"] for item in batch], dim=0)
1237
  collated: dict = {"images": images}
 
1246
 
1247
  def local_kadid_pristine_collate_fn(batch: List[dict]) -> dict:
1248
  """
1249
+ Collate for LocalKadidPristinePresavedDataset.
1250
 
1251
+ Returns:
1252
  images: Tensor (B, C, H, W)
1253
+ masks: Tensor (B, 1, H, W) if has_masks=True
1254
+ + remaining keys as lists of length B
1255
  """
1256
  images = torch.stack([item["img"] for item in batch], dim=0)
1257
 
1258
  collated: dict = {"images": images}
1259
 
1260
+ # If masks are present in the batch
1261
  if "mask" in batch[0]:
1262
  masks = torch.stack([item["mask"] for item in batch], dim=0)
1263
  collated["masks"] = masks
 
1285
  )
1286
  dataset_root = Path(dataset_images_root(str(root), dataset))
1287
  path = Path(path_from_meta)
1288
+ if path.is_absolute():
1289
+ parts = Path(path).parts
1290
+ i = parts.index(dataset)
1291
+ suffix = Path(*parts[i:])
1292
+ path = dataset_root / suffix
1293
+ else:
1294
+ path = dataset_root / path
1295
+ return path
1296
 
1297
 
1298
  def dataset_image_paths(
1299
  dataset: str,
1300
+ dataset_root: str,
1301
  crop_size: int,
1302
  min_distortion_level: int,
1303
  ) -> List[str]:
1304
  if dataset == 'kadid10k':
1305
  ds = Kadid10kDataset(
1306
+ dataset_root,
1307
  crop_size=crop_size,
1308
  min_distortion_level=min_distortion_level,
1309
  )
1310
  return [_to_relative_dataset_path(Path(p), ds.root) for p in ds.images]
1311
 
1312
  if dataset == 'local_kadid':
1313
+ ds = LocalKadidPresavedDataset(dataset_root, crop_size=crop_size)
1314
  return [_to_relative_dataset_path(Path(str(p)), ds.root) for p in ds.df['distorted_img_path'].tolist()]
1315
 
1316
  if dataset == 'QGround':
1317
+ ds = QGroundDataset(dataset_root, split='test', crop_size=crop_size)
1318
  return [str(sample['image_rel']) for sample in ds.samples]
1319
 
1320
  if dataset == 'SRGround':
1321
+ ds = SRGroundSmallDataset(dataset_root, json_path='srground_train.json', allowed_methods=['DiT4SR_x2'])
1322
  return [_to_relative_dataset_path(Path(str(p)), ds.root) for p in ds.df['image_path'].tolist()]
1323
 
1324
  raise ValueError(f'Unsupported dataset: {dataset}')
analysis/features/feature_filters.py CHANGED
@@ -85,57 +85,57 @@ def my_kruskal(
85
  col_data: np.ndarray,
86
  col_indices: np.ndarray,
87
  n_samples: int,
88
- group_indices: list[np.ndarray], # список массивов индексов для каждой группы
89
  ) -> float:
90
  """
91
- Kruskal-Wallis H-test для одной колонки CSC-матрицы.
92
 
93
- Параметры
94
  ----------
95
- col_data : ненулевые значения колонки (csc.data[csc.indptr[j]:csc.indptr[j+1]])
96
- col_indices : строковые индексы ненулевых элементов (csc.indices[...])
97
- n_samples : полное число строк (n)
98
- group_indices: group_indices[g] — массив строковых индексов группы g
99
-
100
- Возвращает
101
- ----------
102
- p-value (float), или np.nan если тест неприменим
103
  """
104
  n = n_samples
105
  n_zeros = n - len(col_data)
106
 
107
- # --- 1. Ранги ненулевых элементов среди ВСЕХ n значений ----------------
108
- # Нули занимают позиции 1..n_zeros в общем порядке.
109
- # Ненулевые элементы начинаются с позиции n_zeros + 1.
110
 
111
- # Сортируем ненулевые значения, получаем ранги внутри них
112
  nonzero_vals = col_data.astype(np.float64)
113
  order = np.argsort(nonzero_vals, kind='stable')
114
- # Обрабатываем ties: каждому уникальному значению средний ранг
115
  ranks_local = np.empty(len(nonzero_vals), dtype=np.float64)
116
- # временно назначаем 1-based ранги среди ненулевых
117
  ranks_local[order] = np.arange(1, len(nonzero_vals) + 1, dtype=np.float64)
118
- # Разрешаем ties усреднением
119
  sorted_vals = nonzero_vals[order]
120
  unique_vals, inverse, counts = np.unique(sorted_vals, return_inverse=True, return_counts=True)
121
- # средний ранг для каждого уникального значения (1-based среди ненулевых)
122
  cum = np.concatenate([[0], np.cumsum(counts)])
123
  mean_local_ranks = (cum[:-1] + cum[1:] + 1) / 2.0 # (first + last + 1) / 2
124
  ranks_local = mean_local_ranks[inverse]
125
 
126
- # Сдвигаем ранги ненулевых элементов на n_zeros (они все правее нулей)
127
- # Но нужно учесть ties между нулями и ненулевыми.
128
- global_ranks_nonzero = ranks_local + n_zeros # сдвиг на количество нулей
129
 
130
- # Средний ранг нулей: нули занимают ранги 1..n_zeros
131
  zero_mean_rank = (n_zeros + 1) / 2.0 if n_zeros > 0 else 0.0
132
 
133
- # --- 2. Для каждой группы считаем сумму рангов -------------------------
134
  n_groups = len(group_indices)
135
  if n_groups < 2:
136
  return np.nan
137
 
138
- # Маска ненулевых: строим dict index->global_rank для быстрого lookup
139
  rank_map = dict(zip(col_indices, global_ranks_nonzero))
140
 
141
  H_num = 0.0
@@ -149,7 +149,7 @@ def my_kruskal(
149
  if ng == 0:
150
  continue
151
 
152
- # Ранги элементов группы
153
  r_sum = 0.0
154
  for i in g_idx:
155
  r_sum += rank_map.get(int(i), zero_mean_rank)
@@ -161,9 +161,9 @@ def my_kruskal(
161
  if len(group_sizes) < 2:
162
  return np.nan
163
 
164
- n_t = n_total_valid # должно равняться n
165
 
166
- # --- 3. H-статистика (стандартная формула) ------------------------------
167
  # H = 12 / (n*(n+1)) * sum(R_i^2 / n_i) - 3*(n+1)
168
  H = 0.0
169
  for ng, rs in zip(group_sizes, group_rank_sums):
@@ -171,13 +171,13 @@ def my_kruskal(
171
 
172
  H = (12.0 / (n_t * (n_t + 1))) * H - 3.0 * (n_t + 1)
173
 
174
- # --- 4. Поправка на ties ------------------------------------------------
175
- # T = sum(t^3 - t) для каждой группы ties, C = 1 - T / (n^3 - n)
176
- # Ties среди ненулевых:
177
  tie_correction = 1.0
178
  if len(unique_vals) < len(nonzero_vals):
179
  T = np.sum(counts ** 3 - counts, dtype=np.float64)
180
- # Добавляем ties нулей:
181
  if n_zeros > 1:
182
  T += n_zeros ** 3 - n_zeros
183
  denom = float(n_t) ** 3 - n_t
@@ -190,7 +190,7 @@ def my_kruskal(
190
  if H < 0:
191
  H = 0.0
192
 
193
- # --- 5. p-value из chi2 с df = n_groups - 1 ----------------------------
194
  df = len(group_sizes) - 1
195
  p_value = float(stats.chi2.sf(H, df))
196
  return p_value
@@ -314,7 +314,7 @@ class KruskalWallisFeatureFilter(BaseFeatureFilter):
314
  except Exception as exc:
315
  print(f'[cache][feature_filter] invalid cache {cache_path}: {exc}. Recomputing...')
316
 
317
- # Предвычисляем индексы строк для каждой группы — один раз
318
  group_indices = []
319
  for group in unique_groups:
320
  idx = np.where(group_values == group)[0]
@@ -331,7 +331,7 @@ class KruskalWallisFeatureFilter(BaseFeatureFilter):
331
  col_data = codes_csc.data[start:stop]
332
  col_indices = codes_csc.indices[start:stop]
333
 
334
- # Пропускаем константные колонки
335
  n_nonzero = stop - start
336
  all_same = (
337
  n_nonzero == 0
@@ -339,9 +339,9 @@ class KruskalWallisFeatureFilter(BaseFeatureFilter):
339
  or (n_nonzero < n_samples and n_nonzero == 0)
340
  )
341
  if all_same and n_nonzero == 0:
342
- continue # все нуликонстанта
343
  if n_nonzero > 0 and n_nonzero == n_samples and np.all(col_data == col_data[0]):
344
- continue # все одинаковые ненулевые
345
 
346
  p_value = my_kruskal(col_data, col_indices, n_samples, group_indices)
347
 
 
85
  col_data: np.ndarray,
86
  col_indices: np.ndarray,
87
  n_samples: int,
88
+ group_indices: list[np.ndarray], # list of index arrays, one per group
89
  ) -> float:
90
  """
91
+ Kruskal-Wallis H-test for a single CSC matrix column.
92
 
93
+ Parameters
94
  ----------
95
+ col_data : nonzero column values (csc.data[csc.indptr[j]:csc.indptr[j+1]])
96
+ col_indices : row indices of nonzero elements (csc.indices[...])
97
+ n_samples : total number of rows (n)
98
+ group_indices: group_indices[g] — array of row indices for group g
99
+
100
+ Returns
101
+ -------
102
+ p-value (float), or np.nan if the test is not applicable
103
  """
104
  n = n_samples
105
  n_zeros = n - len(col_data)
106
 
107
+ # --- 1. Ranks of nonzero elements among ALL n values -------------------
108
+ # Zeros occupy positions 1..n_zeros in the overall order.
109
+ # Nonzero elements start at position n_zeros + 1.
110
 
111
+ # Sort nonzero values and compute ranks within them
112
  nonzero_vals = col_data.astype(np.float64)
113
  order = np.argsort(nonzero_vals, kind='stable')
114
+ # Handle ties: assign each unique value its mean rank
115
  ranks_local = np.empty(len(nonzero_vals), dtype=np.float64)
116
+ # Temporarily assign 1-based ranks among nonzero values
117
  ranks_local[order] = np.arange(1, len(nonzero_vals) + 1, dtype=np.float64)
118
+ # Resolve ties by averaging
119
  sorted_vals = nonzero_vals[order]
120
  unique_vals, inverse, counts = np.unique(sorted_vals, return_inverse=True, return_counts=True)
121
+ # mean rank for each unique value (1-based among nonzero values)
122
  cum = np.concatenate([[0], np.cumsum(counts)])
123
  mean_local_ranks = (cum[:-1] + cum[1:] + 1) / 2.0 # (first + last + 1) / 2
124
  ranks_local = mean_local_ranks[inverse]
125
 
126
+ # Shift nonzero ranks by n_zeros (they all lie to the right of zeros)
127
+ # Ties between zeros and nonzero values must be accounted for.
128
+ global_ranks_nonzero = ranks_local + n_zeros # shift by the number of zeros
129
 
130
+ # Mean rank of zeros: zeros occupy ranks 1..n_zeros
131
  zero_mean_rank = (n_zeros + 1) / 2.0 if n_zeros > 0 else 0.0
132
 
133
+ # --- 2. Sum ranks for each group ----------------------------------------
134
  n_groups = len(group_indices)
135
  if n_groups < 2:
136
  return np.nan
137
 
138
+ # Nonzero mask: build index->global_rank dict for fast lookup
139
  rank_map = dict(zip(col_indices, global_ranks_nonzero))
140
 
141
  H_num = 0.0
 
149
  if ng == 0:
150
  continue
151
 
152
+ # Ranks of group elements
153
  r_sum = 0.0
154
  for i in g_idx:
155
  r_sum += rank_map.get(int(i), zero_mean_rank)
 
161
  if len(group_sizes) < 2:
162
  return np.nan
163
 
164
+ n_t = n_total_valid # should equal n
165
 
166
+ # --- 3. H-statistic (standard formula) ----------------------------------
167
  # H = 12 / (n*(n+1)) * sum(R_i^2 / n_i) - 3*(n+1)
168
  H = 0.0
169
  for ng, rs in zip(group_sizes, group_rank_sums):
 
171
 
172
  H = (12.0 / (n_t * (n_t + 1))) * H - 3.0 * (n_t + 1)
173
 
174
+ # --- 4. Tie correction --------------------------------------------------
175
+ # T = sum(t^3 - t) for each tie group, C = 1 - T / (n^3 - n)
176
+ # Ties among nonzero values:
177
  tie_correction = 1.0
178
  if len(unique_vals) < len(nonzero_vals):
179
  T = np.sum(counts ** 3 - counts, dtype=np.float64)
180
+ # Add ties among zeros:
181
  if n_zeros > 1:
182
  T += n_zeros ** 3 - n_zeros
183
  denom = float(n_t) ** 3 - n_t
 
190
  if H < 0:
191
  H = 0.0
192
 
193
+ # --- 5. p-value from chi2 with df = n_groups - 1 ------------------------
194
  df = len(group_sizes) - 1
195
  p_value = float(stats.chi2.sf(H, df))
196
  return p_value
 
314
  except Exception as exc:
315
  print(f'[cache][feature_filter] invalid cache {cache_path}: {exc}. Recomputing...')
316
 
317
+ # Precompute row indices for each group once
318
  group_indices = []
319
  for group in unique_groups:
320
  idx = np.where(group_values == group)[0]
 
331
  col_data = codes_csc.data[start:stop]
332
  col_indices = codes_csc.indices[start:stop]
333
 
334
+ # Skip constant columns
335
  n_nonzero = stop - start
336
  all_same = (
337
  n_nonzero == 0
 
339
  or (n_nonzero < n_samples and n_nonzero == 0)
340
  )
341
  if all_same and n_nonzero == 0:
342
+ continue # all zerosconstant
343
  if n_nonzero > 0 and n_nonzero == n_samples and np.all(col_data == col_data[0]):
344
+ continue # all identical nonzero values
345
 
346
  p_value = my_kruskal(col_data, col_indices, n_samples, group_indices)
347
 
analysis/features/feature_matrix.py CHANGED
@@ -3,10 +3,17 @@ from __future__ import annotations
3
  from typing import List, Sequence
4
 
5
  import numpy as np
 
6
 
7
  from analysis.features.feature_indexing import FeatureMatrix
8
 
9
 
 
 
 
 
 
 
10
  def _zscore_columns(x: np.ndarray) -> np.ndarray:
11
  mu = x.mean(axis=0, keepdims=True)
12
  sigma = x.std(axis=0, keepdims=True)
@@ -40,7 +47,7 @@ def build_image_feature_matrix(
40
 
41
  `image_row_indices` is a sequence where each element is an array of row indices
42
  (patch indices) belonging to that image.
43
- Aggregation modes: 'max', 'sum', 'mean_acts' (mean over positive activations) or 'mean'.
44
  """
45
  return _zscore_columns(
46
  build_image_feature_matrix_raw(
@@ -52,6 +59,96 @@ def build_image_feature_matrix(
52
  )
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  def build_image_feature_matrix_raw(
56
  features: FeatureMatrix,
57
  image_row_indices: Sequence[Sequence[int]],
@@ -64,37 +161,19 @@ def build_image_feature_matrix_raw(
64
  skips the final z-score normalization so it can be used for paired deltas.
65
  """
66
  mode = str(aggregation_mode)
67
- if mode not in {'max', 'sum', 'mean_acts', 'mean'}:
68
  raise ValueError(f'Unknown aggregation mode: {mode!r}')
69
 
70
- n_features_total = features.n_features
71
  codes_subset = features.codes
72
- x = np.zeros((int(n_images_used), int(n_features_total)), dtype=np.float32)
73
-
74
  for img_i, row_ids in enumerate(image_row_indices):
75
  if len(row_ids) == 0:
76
  continue
77
- chunk = codes_subset[row_ids, :]
78
- if mode == 'max':
79
- x[img_i] = np.asarray(chunk.max(axis=0)).ravel().astype(np.float32)
80
- elif mode == 'sum':
81
- x[img_i] = np.asarray(chunk.sum(axis=0)).ravel().astype(np.float32)
82
- elif mode == 'mean':
83
- x[img_i] = np.asarray(chunk.mean(axis=0)).ravel().astype(np.float32)
84
- else: # mean_acts: mean over positive activations
85
- if hasattr(chunk, 'multiply'):
86
- positive_mask = chunk > 0
87
- positive_chunk = chunk.multiply(positive_mask)
88
- pos_sum = np.asarray(positive_chunk.sum(axis=0)).ravel().astype(np.float32)
89
- pos_count = np.asarray(positive_mask.sum(axis=0)).ravel().astype(np.float32)
90
- else:
91
- chunk_arr = np.asarray(chunk, dtype=np.float32)
92
- positive_mask = chunk_arr > 0
93
- pos_sum = np.asarray((chunk_arr * positive_mask).sum(axis=0)).ravel().astype(np.float32)
94
- pos_count = np.asarray(positive_mask.sum(axis=0)).ravel().astype(np.float32)
95
- mean_pos = np.zeros(int(n_features_total), dtype=np.float32)
96
- nonzero = pos_count > 0
97
- mean_pos[nonzero] = pos_sum[nonzero] / pos_count[nonzero]
98
- x[img_i] = mean_pos
99
 
100
- return x
 
 
 
 
 
 
3
  from typing import List, Sequence
4
 
5
  import numpy as np
6
+ import scipy.sparse as sp
7
 
8
  from analysis.features.feature_indexing import FeatureMatrix
9
 
10
 
11
+ def _axis0_to_dense(values) -> np.ndarray:
12
+ if hasattr(values, 'toarray'):
13
+ return np.asarray(values.toarray()).ravel().astype(np.float32)
14
+ return np.asarray(values).ravel().astype(np.float32)
15
+
16
+
17
  def _zscore_columns(x: np.ndarray) -> np.ndarray:
18
  mu = x.mean(axis=0, keepdims=True)
19
  sigma = x.std(axis=0, keepdims=True)
 
47
 
48
  `image_row_indices` is a sequence where each element is an array of row indices
49
  (patch indices) belonging to that image.
50
+ Aggregation modes: 'max', 'sum', 'mean_acts' (mean over positive activations only).
51
  """
52
  return _zscore_columns(
53
  build_image_feature_matrix_raw(
 
59
  )
60
 
61
 
62
+ def _to_dense_image_features(values) -> np.ndarray:
63
+ if sp.issparse(values):
64
+ return np.asarray(values.toarray(), dtype=np.float32)
65
+ return np.asarray(values, dtype=np.float32)
66
+
67
+
68
+ def _image_group_matrix(image_idx_arr: np.ndarray, n_images_used: int) -> sp.csr_matrix:
69
+ """Map each patch row to one image column (n_patches, n_images)."""
70
+ n_patches = int(len(image_idx_arr))
71
+ rows = np.arange(n_patches, dtype=np.int32)
72
+ cols = np.asarray(image_idx_arr, dtype=np.int32)
73
+ return sp.csr_matrix(
74
+ (np.ones(n_patches, dtype=np.float32), (rows, cols)),
75
+ shape=(n_patches, int(n_images_used)),
76
+ )
77
+
78
+
79
+ def _grouped_sum(codes, group: sp.csr_matrix) -> np.ndarray:
80
+ return _to_dense_image_features(group.T @ codes)
81
+
82
+
83
+ def _grouped_mean_acts(codes, group: sp.csr_matrix) -> np.ndarray:
84
+ if sp.issparse(codes):
85
+ positive = codes.multiply(codes > 0)
86
+ pos_count = group.T @ (codes > 0).astype(np.float32)
87
+ else:
88
+ codes_arr = np.asarray(codes, dtype=np.float32)
89
+ positive = codes_arr * (codes_arr > 0)
90
+ pos_count = group.T @ (codes_arr > 0).astype(np.float32)
91
+
92
+ pos_sum = _to_dense_image_features(group.T @ positive)
93
+ pos_count = _to_dense_image_features(pos_count)
94
+ out = np.zeros_like(pos_sum, dtype=np.float32)
95
+ nonzero = pos_count > 0
96
+ out[nonzero] = pos_sum[nonzero] / pos_count[nonzero]
97
+ return out
98
+
99
+
100
+ def _grouped_max(codes, image_idx_arr: np.ndarray, n_images_used: int, n_features_total: int) -> np.ndarray:
101
+ x = np.zeros((int(n_images_used), int(n_features_total)), dtype=np.float32)
102
+ for img_i in range(int(n_images_used)):
103
+ patch_mask = image_idx_arr == img_i
104
+ if not np.any(patch_mask):
105
+ continue
106
+ chunk = codes[patch_mask, :]
107
+ x[img_i] = _axis0_to_dense(chunk.max(axis=0))
108
+ return x
109
+
110
+
111
+ def _aggregate_codes_by_image_idx(
112
+ codes,
113
+ image_idx_arr: np.ndarray,
114
+ n_images_used: int,
115
+ aggregation_mode: str,
116
+ ) -> np.ndarray:
117
+ """Aggregate patch-level codes per image using dense image_idx labels."""
118
+ mode = str(aggregation_mode)
119
+ if mode not in {'max', 'sum', 'mean_acts'}:
120
+ raise ValueError(f'Unknown aggregation mode: {mode!r}')
121
+
122
+ image_idx_arr = np.asarray(image_idx_arr, dtype=np.int32)
123
+ n_images_used = int(n_images_used)
124
+ n_features_total = int(codes.shape[1])
125
+
126
+ if mode == 'max':
127
+ return _grouped_max(codes, image_idx_arr, n_images_used, n_features_total)
128
+
129
+ group = _image_group_matrix(image_idx_arr, n_images_used)
130
+ if mode == 'sum':
131
+ return _grouped_sum(codes, group)
132
+ return _grouped_mean_acts(codes, group)
133
+
134
+
135
+ def build_image_feature_matrix_from_image_idx(
136
+ codes,
137
+ image_idx_arr: np.ndarray,
138
+ n_images_used: int,
139
+ aggregation_mode: str = 'max',
140
+ ) -> np.ndarray:
141
+ """Build z-scored image-level matrix using per-patch ``image_idx`` labels."""
142
+ return _zscore_columns(
143
+ _aggregate_codes_by_image_idx(
144
+ codes,
145
+ image_idx_arr,
146
+ n_images_used,
147
+ aggregation_mode,
148
+ )
149
+ )
150
+
151
+
152
  def build_image_feature_matrix_raw(
153
  features: FeatureMatrix,
154
  image_row_indices: Sequence[Sequence[int]],
 
161
  skips the final z-score normalization so it can be used for paired deltas.
162
  """
163
  mode = str(aggregation_mode)
164
+ if mode not in {'max', 'sum', 'mean_acts'}:
165
  raise ValueError(f'Unknown aggregation mode: {mode!r}')
166
 
 
167
  codes_subset = features.codes
168
+ image_idx_arr = np.empty(int(codes_subset.shape[0]), dtype=np.int32)
 
169
  for img_i, row_ids in enumerate(image_row_indices):
170
  if len(row_ids) == 0:
171
  continue
172
+ image_idx_arr[list(row_ids)] = int(img_i)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
 
174
+ return _aggregate_codes_by_image_idx(
175
+ codes_subset,
176
+ image_idx_arr,
177
+ n_images_used,
178
+ mode,
179
+ )
analysis/features/feature_selectors.py CHANGED
@@ -252,7 +252,7 @@ class TopKAbsCorrSelector(BaseFeatureSelector):
252
  name: str = 'topk_abs_corr'
253
  description: str = 'Top-k features by max absolute dist_type correlation.'
254
  corr_key: str = 'corr_type_df'
255
- relation_label: str = 'высокая корреляция'
256
 
257
  def compute_metric_tables(
258
  self,
@@ -316,7 +316,7 @@ class TopKMutualInfoSelector(BaseFeatureSelector):
316
  name: str = 'topk_mi'
317
  description: str = 'Top-k features by max dist_type mutual information.'
318
  mi_key: str = 'mi_type_df'
319
- relation_label: str = 'высокая взаимная информация'
320
 
321
  def compute_metric_tables(
322
  self,
@@ -386,7 +386,7 @@ class TopKRocAucSelector(BaseFeatureSelector):
386
  name: str = 'topk_auc'
387
  description: str = 'Top-k features by one-vs-rest ROC-AUC at dist_type level.'
388
  auc_key: str = 'auc_type_df'
389
- relation_label: str = 'высокая ROC-AUC различимость'
390
 
391
  def compute_metric_tables(
392
  self,
@@ -456,7 +456,7 @@ class TopKPairedDeltaSelector(BaseFeatureSelector):
456
  name: str = 'paired_delta'
457
  description: str = 'Top-k features by max relative paired activation delta.'
458
  delta_key: str = 'paired_relative_dist_type_df'
459
- relation_label: str = 'высокая парная дельта активаций'
460
 
461
  @staticmethod
462
  def _delta_mode_from_key(delta_key: str) -> str:
@@ -636,7 +636,7 @@ class TopKIoUSelector(BaseFeatureSelector):
636
 
637
  name: str = 'topk_iou_type'
638
  description: str = 'Top-k features by median IoU with distortion regions at dist_type level.'
639
- relation_label: str = 'высокая пространственная локализация'
640
 
641
  def compute_metric_tables(
642
  self,
 
252
  name: str = 'topk_abs_corr'
253
  description: str = 'Top-k features by max absolute dist_type correlation.'
254
  corr_key: str = 'corr_type_df'
255
+ relation_label: str = 'high correlation'
256
 
257
  def compute_metric_tables(
258
  self,
 
316
  name: str = 'topk_mi'
317
  description: str = 'Top-k features by max dist_type mutual information.'
318
  mi_key: str = 'mi_type_df'
319
+ relation_label: str = 'high mutual information'
320
 
321
  def compute_metric_tables(
322
  self,
 
386
  name: str = 'topk_auc'
387
  description: str = 'Top-k features by one-vs-rest ROC-AUC at dist_type level.'
388
  auc_key: str = 'auc_type_df'
389
+ relation_label: str = 'high ROC-AUC separability'
390
 
391
  def compute_metric_tables(
392
  self,
 
456
  name: str = 'paired_delta'
457
  description: str = 'Top-k features by max relative paired activation delta.'
458
  delta_key: str = 'paired_relative_dist_type_df'
459
+ relation_label: str = 'high paired activation delta'
460
 
461
  @staticmethod
462
  def _delta_mode_from_key(delta_key: str) -> str:
 
636
 
637
  name: str = 'topk_iou_type'
638
  description: str = 'Top-k features by median IoU with distortion regions at dist_type level.'
639
+ relation_label: str = 'high spatial localization'
640
 
641
  def compute_metric_tables(
642
  self,
analysis/features/feature_stats.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Статистики по SAE-признакам и их визуализация (bar charts).
3
  """
4
 
5
  from typing import List, Optional, Tuple
@@ -13,19 +13,19 @@ from analysis.features.feature_indexing import FeatureMatrix
13
 
14
  def compute_feature_stats(features: FeatureMatrix) -> pd.DataFrame:
15
  """
16
- Вычисляет статистики для каждого признака SAE.
17
 
18
- Параметры
19
  ----------
20
  features : CSR activations; ``column_feature_ids[j]`` = global SAE id
21
 
22
- Возвращает
23
- ----------
24
- DataFrame с колонками: feature_id (global SAE id), mean, frequency, max, mean_acts
25
- mean — средняя активация по всем патчам
26
- frequency — доля патчей с ненулевой активацией
27
- max — максимальная активация среди всех патчей
28
- mean_acts — средняя активация по ненулевым патчам
29
  """
30
  codes = features.codes
31
  mat = codes if codes.dtype == np.float32 else codes.astype(np.float32)
@@ -55,18 +55,18 @@ def get_top_features(
55
  min_mean_acts: Optional[float] = None,
56
  ) -> List[int]:
57
  """
58
- Возвращает индексы top-K признаков по выбранному критерию.
59
 
60
- Параметры
61
  ----------
62
- stats : DataFrame из compute_feature_stats
63
- top_k : число топ-признаков
64
  criterion : 'mean' | 'frequency' | 'max' | 'mean_acts'
65
- min_mean_acts : предварительный фильтр по mean_acts
66
 
67
- Возвращает
68
- ----------
69
- List[int] — feature_id в порядке убывания критерия
70
  """
71
  assert criterion in ('mean', 'frequency', 'max', 'mean_acts'), \
72
  f"criterion must be one of 'mean', 'frequency', 'max', 'mean_acts', got {criterion!r}"
@@ -84,8 +84,8 @@ def plot_top_features(
84
  min_mean_acts: Optional[float] = None,
85
  ) -> None:
86
  """
87
- Bar-chart топ-K признаков по выбранному критерию.
88
- Над каждым баром подписывается frequency (доля ненулевых патчей).
89
  """
90
  top_ids = get_top_features(stats, top_k=top_k, criterion=criterion,
91
  min_mean_acts=min_mean_acts)
 
1
  """
2
+ SAE feature statistics and visualization (bar charts).
3
  """
4
 
5
  from typing import List, Optional, Tuple
 
13
 
14
  def compute_feature_stats(features: FeatureMatrix) -> pd.DataFrame:
15
  """
16
+ Compute statistics for each SAE feature.
17
 
18
+ Parameters
19
  ----------
20
  features : CSR activations; ``column_feature_ids[j]`` = global SAE id
21
 
22
+ Returns
23
+ -------
24
+ DataFrame with columns: feature_id (global SAE id), mean, frequency, max, mean_acts
25
+ mean — mean activation across all patches
26
+ frequency — fraction of patches with nonzero activation
27
+ max — maximum activation among all patches
28
+ mean_acts — mean activation over nonzero patches
29
  """
30
  codes = features.codes
31
  mat = codes if codes.dtype == np.float32 else codes.astype(np.float32)
 
55
  min_mean_acts: Optional[float] = None,
56
  ) -> List[int]:
57
  """
58
+ Return top-K feature indices by the selected criterion.
59
 
60
+ Parameters
61
  ----------
62
+ stats : DataFrame from compute_feature_stats
63
+ top_k : number of top features
64
  criterion : 'mean' | 'frequency' | 'max' | 'mean_acts'
65
+ min_mean_acts : preliminary filter on mean_acts
66
 
67
+ Returns
68
+ -------
69
+ List[int] — feature_id in descending order of the criterion
70
  """
71
  assert criterion in ('mean', 'frequency', 'max', 'mean_acts'), \
72
  f"criterion must be one of 'mean', 'frequency', 'max', 'mean_acts', got {criterion!r}"
 
84
  min_mean_acts: Optional[float] = None,
85
  ) -> None:
86
  """
87
+ Bar chart of top-K features by the selected criterion.
88
+ Each bar is labeled with frequency (fraction of nonzero patches).
89
  """
90
  top_ids = get_top_features(stats, top_k=top_k, criterion=criterion,
91
  min_mean_acts=min_mean_acts)
analysis/metrics/correlations.py CHANGED
@@ -21,8 +21,8 @@ def _prepare_codes_and_categories(
21
  feature_names = [int(fid) for fid in features.column_feature_ids]
22
 
23
  if binarize:
24
- threshold = 0.2 # Такой был в PatchSAE
25
- print(f'[binarize] Бинаризация с порогом {threshold}')
26
  if sp.issparse(codes):
27
  codes = codes.copy()
28
  codes.data = (codes.data > threshold).astype(np.float32)
@@ -74,7 +74,6 @@ def compute_distortion_correlations(
74
  cached = load_parquet_cache(cache_path, label='correlations')
75
  if cached is not None:
76
  return cached
77
-
78
  work_features = features.subset(global_feature_ids)
79
 
80
  work_codes, _, feature_names, unique_categories, cat_idx = _prepare_codes_and_categories(
 
21
  feature_names = [int(fid) for fid in features.column_feature_ids]
22
 
23
  if binarize:
24
+ threshold = 0.2 # Same value as in PatchSAE
25
+ print(f'[binarize] Binarizing with threshold {threshold}')
26
  if sp.issparse(codes):
27
  codes = codes.copy()
28
  codes.data = (codes.data > threshold).astype(np.float32)
 
74
  cached = load_parquet_cache(cache_path, label='correlations')
75
  if cached is not None:
76
  return cached
 
77
  work_features = features.subset(global_feature_ids)
78
 
79
  work_codes, _, feature_names, unique_categories, cat_idx = _prepare_codes_and_categories(
analysis/models.py CHANGED
@@ -15,16 +15,16 @@ _hook_handle = None
15
 
16
 
17
  def _make_hook(name: str, sequence_layout: str = 'blc'):
18
- """Forward hook для ARNIQA/MANIQA/LIQE.
19
 
20
- Поддерживаемые форматы выхода слоя:
21
  - (B, C, H, W) -> (B*H*W, C)
22
  - (B, L, C) -> (B*L, C) (MANIQA)
23
  - (L, B, C) -> (B*L, C) (LIQE/LIQE-MIX)
24
  """
25
  def hook(module, inp, out):
26
  if hasattr(out, 'last_hidden_state'):
27
- out_detached = out.last_hidden_state.detach() # Пока костыль
28
  else:
29
  out_detached = out.detach()
30
  if out_detached.ndim == 4:
@@ -101,23 +101,23 @@ def load_sae(
101
  sae_config: Optional[Dict[str, Any]] = None,
102
  **overrides: Any,
103
  ) -> torch.nn.Module:
104
- """Загружает SAE из чекпоинта, определяя архитектуру из JSON-конфига.
105
 
106
  Parameters
107
  ----------
108
  checkpoint_path : str
109
- Путь к директории чекпоинта (output_dir/checkpoint-<step>/) или файлу весов.
110
  config_path : str, optional
111
- Путь к sae_config.json. По умолчанию ищется в родительской директории
112
- чекпоинта (т.е. output_dir/sae_config.json).
113
  device : str
114
- Устройство для загрузки.
115
  dtype : torch.dtype
116
- Тип данных для загрузки.
117
  sae_config : dict, optional
118
- Уже загруженный конфиг.
119
  **overrides
120
- Переопределяют отдельные поля конфига (например, mp_threshold=0.05).
121
  """
122
  import accelerate
123
  from train_code.sae import SAE, MatchingPursuitSAE
@@ -159,17 +159,17 @@ def load_iqa_model(
159
  swin_num: int = 2,
160
  ):
161
  """
162
- Загружает IQA-модель и регистрирует hook на выбранный слой.
163
 
164
- Поддерживаемые метрики:
165
- - arniqa-kadid: hook на iqa.net.encoder[layer_num]
166
- - maniqa: hook на iqa.net.swintransformer{swin_num}.layers[layer_num]
167
- - liqe / liqe_mix: hook на iqa.net.clip_model.visual.transformer.resblocks[layer_num]
168
 
169
- Возвращает
170
- ----------
171
- iqa : загруженная IQA-модель
172
- layer_name : строковый ключ, под которым активации хранятся в _iqa_activations
173
  """
174
  import pyiqa
175
 
 
15
 
16
 
17
  def _make_hook(name: str, sequence_layout: str = 'blc'):
18
+ """Forward hook for ARNIQA/MANIQA/LIQE.
19
 
20
+ Supported layer output formats:
21
  - (B, C, H, W) -> (B*H*W, C)
22
  - (B, L, C) -> (B*L, C) (MANIQA)
23
  - (L, B, C) -> (B*L, C) (LIQE/LIQE-MIX)
24
  """
25
  def hook(module, inp, out):
26
  if hasattr(out, 'last_hidden_state'):
27
+ out_detached = out.last_hidden_state.detach() # Temporary workaround
28
  else:
29
  out_detached = out.detach()
30
  if out_detached.ndim == 4:
 
101
  sae_config: Optional[Dict[str, Any]] = None,
102
  **overrides: Any,
103
  ) -> torch.nn.Module:
104
+ """Load SAE from a checkpoint, inferring architecture from the JSON config.
105
 
106
  Parameters
107
  ----------
108
  checkpoint_path : str
109
+ Path to the checkpoint directory (output_dir/checkpoint-<step>/) or weights file.
110
  config_path : str, optional
111
+ Path to sae_config.json. By default, looked up in the parent directory
112
+ of the checkpoint (i.e. output_dir/sae_config.json).
113
  device : str
114
+ Device to load onto.
115
  dtype : torch.dtype
116
+ Data type for loading.
117
  sae_config : dict, optional
118
+ Pre-loaded config.
119
  **overrides
120
+ Override individual config fields (e.g. mp_threshold=0.05).
121
  """
122
  import accelerate
123
  from train_code.sae import SAE, MatchingPursuitSAE
 
159
  swin_num: int = 2,
160
  ):
161
  """
162
+ Load an IQA model and register a hook on the selected layer.
163
 
164
+ Supported metrics:
165
+ - arniqa-kadid: hook on iqa.net.encoder[layer_num]
166
+ - maniqa: hook on iqa.net.swintransformer{swin_num}.layers[layer_num]
167
+ - liqe / liqe_mix: hook on iqa.net.clip_model.visual.transformer.resblocks[layer_num]
168
 
169
+ Returns
170
+ -------
171
+ iqa : loaded IQA model
172
+ layer_name : string key under which activations are stored in _iqa_activations
173
  """
174
  import pyiqa
175
 
analysis/utils.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Общие вспомогательные утилиты пакета analysis.
3
  """
4
 
5
  from typing import List
@@ -18,25 +18,25 @@ def get_top_images_for_feature(
18
  aggregation: str = 'mean_acts',
19
  ) -> List[int]:
20
  """
21
- Возвращает индексы изображений, на которых признак feature_id активируется сильнее всего.
22
 
23
- Активации патчей внутри одного изображения агрегируются в одно скалярное значение,
24
- после чего изображения сортируются по убыванию.
25
 
26
- Параметры
27
  ----------
28
  features : CSR activations with global id per column
29
- meta : DataFrame с колонкой 'image_idx' (один патч одна строка)
30
  feature_id : global SAE feature id
31
- top_n : число возвращаемых изображений
32
  aggregation : 'mean_acts' | 'max' | 'sum'
33
- mean_acts — среднее по патчам с активацией > 0
34
- max — максимальная активация среди патчей
35
- sum — сумма всех активаций
36
 
37
- Возвращает
38
- ----------
39
- List[int] — image_idx в порядке убывания агрегированной активации
40
  """
41
  assert aggregation in ('mean_acts', 'max', 'sum'), (
42
  f"aggregation must be 'mean_acts', 'max' or 'sum', got {aggregation!r}"
@@ -72,11 +72,11 @@ def get_top_images_for_feature_by_iou(
72
  dataset: str | None = None,
73
  ) -> List[int]:
74
  """
75
- Возвращает индексы изображений с наибольшим IoU между бинарной картой
76
- активаций признака и маской искажений для каждого изображения.
77
 
78
- Требует, чтобы в `meta` были колонки `image_idx` и либо `patch_mask_label`,
79
- либо `patch_is_distorted` (см. iou_utils._load_patch_mask_for_group).
80
  """
81
  from analysis.metrics import iou_utils
82
 
 
1
  """
2
+ Shared helper utilities for the analysis package.
3
  """
4
 
5
  from typing import List
 
18
  aggregation: str = 'mean_acts',
19
  ) -> List[int]:
20
  """
21
+ Return image indices where feature_id activates most strongly.
22
 
23
+ Patch activations within each image are aggregated into a single scalar,
24
+ then images are sorted in descending order.
25
 
26
+ Parameters
27
  ----------
28
  features : CSR activations with global id per column
29
+ meta : DataFrame with an 'image_idx' column (one patch per row)
30
  feature_id : global SAE feature id
31
+ top_n : number of images to return
32
  aggregation : 'mean_acts' | 'max' | 'sum'
33
+ mean_acts — mean over patches with activation > 0
34
+ max — maximum activation among patches
35
+ sum — sum of all activations
36
 
37
+ Returns
38
+ -------
39
+ List[int] — image_idx in descending order of aggregated activation
40
  """
41
  assert aggregation in ('mean_acts', 'max', 'sum'), (
42
  f"aggregation must be 'mean_acts', 'max' or 'sum', got {aggregation!r}"
 
72
  dataset: str | None = None,
73
  ) -> List[int]:
74
  """
75
+ Return image indices with the highest IoU between the feature's binary
76
+ activation map and the distortion mask for each image.
77
 
78
+ Requires `image_idx` and either `patch_mask_label` or `patch_is_distorted`
79
+ in `meta` (see iou_utils._load_patch_mask_for_group).
80
  """
81
  from analysis.metrics import iou_utils
82
 
analysis/viz/umap_utils.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
 
3
  import hashlib
4
  import json
 
5
  import pickle
6
  import re
7
  import time
@@ -9,10 +10,62 @@ from pathlib import Path
9
  from typing import Any, Callable, Dict, Hashable, Mapping, Optional
10
 
11
  import numpy as np
 
12
  import umap
13
 
14
  _UMAP_CACHE: Dict[Hashable, Dict[str, Any]] = {}
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  def _json_default(value: object) -> object:
18
  if isinstance(value, (np.integer, np.floating)):
@@ -116,36 +169,40 @@ def compute_umap_from_features(features: np.ndarray, umap_params: Dict) -> Dict[
116
 
117
 
118
  def get_or_compute_umap_with_builder(
119
- key: Hashable,
120
  build_features_fn: Callable[[], np.ndarray],
121
  umap_params: Dict,
122
- cache: Optional[Dict[Hashable, Dict[str, Any]]] = None,
123
  *,
 
 
 
124
  cache_dir: str | Path | None = None,
125
- cache_id: str | None = None,
126
- cache_signature: Mapping[str, object] | None = None,
127
  ) -> Dict[str, Any]:
128
  """Get or compute UMAP embedding using a builder function to produce features.
129
 
130
- - ``key``: a hashable cache key (e.g. (feature_id, params_tuple)).
131
  - ``build_features_fn``: zero-arg callable that returns a np.ndarray of shape (n_samples, n_features).
132
  - ``umap_params``: parameters passed to ``umap.UMAP``.
 
 
 
133
  - ``cache``: optional dict to use for caching; if None, module-level cache is used.
134
- - ``cache_dir``: optional directory for on-disk NPZ cache (like feature filters).
135
- - ``cache_id``: stable logical id used in cache filenames (defaults to ``str(key)``).
136
- - ``cache_signature``: optional data fingerprint for disk cache lookup before feature build.
137
 
138
  Returns a dict with embedding and timing and other meta fields. Adds 'cache_hit' flag.
139
  """
 
 
 
 
 
 
 
140
  cache = _UMAP_CACHE if cache is None else cache
141
  if key in cache:
142
  cached = dict(cache[key])
143
  cached["cache_hit"] = True
144
  return cached
145
 
146
- resolved_cache_id = _sanitize_cache_id(cache_id if cache_id is not None else str(key))
147
-
148
- if cache_dir is not None and cache_signature is not None:
149
  disk_cache_path = _resolve_disk_cache_path(
150
  cache_dir=cache_dir,
151
  cache_id=resolved_cache_id,
@@ -169,7 +226,7 @@ def get_or_compute_umap_with_builder(
169
  cache_id=resolved_cache_id,
170
  umap_params=umap_params,
171
  signature=cache_signature,
172
- x_shape=None if cache_signature is not None else x_shape,
173
  )
174
  loaded = _load_umap_from_disk(disk_cache_path)
175
  if loaded is not None:
 
2
 
3
  import hashlib
4
  import json
5
+ import os
6
  import pickle
7
  import re
8
  import time
 
10
  from typing import Any, Callable, Dict, Hashable, Mapping, Optional
11
 
12
  import numpy as np
13
+ import pandas as pd
14
  import umap
15
 
16
  _UMAP_CACHE: Dict[Hashable, Dict[str, Any]] = {}
17
 
18
+ UMAP_IMAGE_LIMIT = 1000
19
+ UMAP_IMAGE_RANDOM_STATE = 42
20
+
21
+
22
+ def select_random_image_indices(
23
+ max_images: int,
24
+ *,
25
+ limit: int = UMAP_IMAGE_LIMIT,
26
+ random_state: int = UMAP_IMAGE_RANDOM_STATE,
27
+ ) -> np.ndarray:
28
+ """Pick up to ``limit`` random unique image_idx values from ``[0, max_images)``."""
29
+ max_images = int(max_images)
30
+ n_pick = min(int(limit), max_images)
31
+ if n_pick <= 0:
32
+ raise ValueError(f'max_images must be positive, got {max_images}')
33
+ rng = np.random.default_rng(int(random_state))
34
+ return np.sort(rng.choice(np.arange(max_images), size=n_pick, replace=False)).astype(np.int32)
35
+
36
+
37
+ def build_kadid_index_lookup(meta_df: pd.DataFrame, kadid_ds: Any, n_images_used: int) -> np.ndarray:
38
+ """Map dense UMAP image index (0..n-1) to ``Kadid10kDataset`` positional index."""
39
+ lookup = np.arange(n_images_used, dtype=np.int32)
40
+ if kadid_ds is None:
41
+ return lookup
42
+
43
+ first_rows = meta_df.groupby('image_idx', sort=True).first()
44
+ kadid_paths = [str(p) for p in kadid_ds.images]
45
+ path_to_idx = {p: i for i, p in enumerate(kadid_paths)}
46
+ base_to_idx = {os.path.basename(p): i for i, p in enumerate(kadid_paths)}
47
+
48
+ for dense_i in range(n_images_used):
49
+ row = first_rows.iloc[dense_i]
50
+ kadid_idx = None
51
+ for col in ('distorted_img_path', 'image_path', 'original_img_path'):
52
+ if col not in row.index:
53
+ continue
54
+ val = row[col]
55
+ if val is None or (isinstance(val, float) and np.isnan(val)):
56
+ continue
57
+ path = str(val)
58
+ kadid_idx = path_to_idx.get(path)
59
+ if kadid_idx is None and not Path(path).is_absolute():
60
+ kadid_idx = path_to_idx.get(str(kadid_ds.root / Path(path)))
61
+ if kadid_idx is None:
62
+ kadid_idx = base_to_idx.get(os.path.basename(path))
63
+ if kadid_idx is not None:
64
+ break
65
+ if kadid_idx is not None:
66
+ lookup[dense_i] = int(kadid_idx)
67
+ return lookup
68
+
69
 
70
  def _json_default(value: object) -> object:
71
  if isinstance(value, (np.integer, np.floating)):
 
169
 
170
 
171
  def get_or_compute_umap_with_builder(
 
172
  build_features_fn: Callable[[], np.ndarray],
173
  umap_params: Dict,
 
174
  *,
175
+ cache_id: str,
176
+ cache_signature: Mapping[str, object],
177
+ cache: Optional[Dict[Hashable, Dict[str, Any]]] = None,
178
  cache_dir: str | Path | None = None,
 
 
179
  ) -> Dict[str, Any]:
180
  """Get or compute UMAP embedding using a builder function to produce features.
181
 
 
182
  - ``build_features_fn``: zero-arg callable that returns a np.ndarray of shape (n_samples, n_features).
183
  - ``umap_params``: parameters passed to ``umap.UMAP``.
184
+ - ``cache_id``: stable logical id used in cache filenames.
185
+ - ``cache_signature``: data fingerprint for cache lookup (paired with ``umap_params`` via
186
+ :func:`build_umap_cache_key` for both in-memory and on-disk cache).
187
  - ``cache``: optional dict to use for caching; if None, module-level cache is used.
188
+ - ``cache_dir``: optional directory for on-disk NPZ cache.
 
 
189
 
190
  Returns a dict with embedding and timing and other meta fields. Adds 'cache_hit' flag.
191
  """
192
+ key = build_umap_cache_key(
193
+ cache_id=str(cache_id),
194
+ umap_params=umap_params,
195
+ signature=cache_signature,
196
+ )
197
+ resolved_cache_id = _sanitize_cache_id(cache_id)
198
+
199
  cache = _UMAP_CACHE if cache is None else cache
200
  if key in cache:
201
  cached = dict(cache[key])
202
  cached["cache_hit"] = True
203
  return cached
204
 
205
+ if cache_dir is not None:
 
 
206
  disk_cache_path = _resolve_disk_cache_path(
207
  cache_dir=cache_dir,
208
  cache_id=resolved_cache_id,
 
226
  cache_id=resolved_cache_id,
227
  umap_params=umap_params,
228
  signature=cache_signature,
229
+ x_shape=None,
230
  )
231
  loaded = _load_umap_from_disk(disk_cache_path)
232
  if loaded is not None:
analysis/viz/vis_heatmaps.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Визуализация тепловых карт SAE-признаков поверх изображений KADID-10k.
3
  """
4
 
5
  import io
@@ -27,25 +27,25 @@ from analysis.datasets import (
27
  QGROUND_DISTORTION_TYPES,
28
  SRGROUND_DISTORTION_TYPES,
29
  SRGROUND_LEGEND_LABELS,
 
30
  available_distortions,
31
  distortion_types_mapping,
32
- _image_rel_from_meta_row,
33
- srground_mask_rgb_for_meta_row,
34
- srground_prominences_by_image_paths,
35
  )
36
 
37
  # Show per-image filenames (and distortion subtitles where enabled) under heatmap panels.
38
  SHOW_HEATMAP_IMAGE_TITLES = False
39
 
40
- MASK_LEGEND_FONTSIZE = 20
41
  MASK_LEGEND_HANDLE_HEIGHT = 1.15
42
  MASK_LEGEND_HANDLE_LENGTH = 1.55
43
 
44
 
45
  def _get_distortion_info(img_path: str):
46
  """
47
- По имени файла KADID (I04_07_05.png) возвращает
48
- (distortion_name, distortion_group) или (None, None) для оригиналов.
49
  """
50
  name = Path(img_path).name
51
  m = _re.match(r'I\d+_(\d+)_(\d+)\.png$', name, _re.IGNORECASE)
@@ -59,8 +59,8 @@ def _get_distortion_info(img_path: str):
59
 
60
  def _get_original_path(distorted_path: str) -> Optional[str]:
61
  """
62
- Из пути к искажённому изображению KADID (I04_07_05.png)
63
- возвращает путь к оригиналу (I04.png).
64
  """
65
  p = Path(distorted_path)
66
  match = _re.match(r'(I\d+)_\d+_\d+\.png$', p.name, _re.IGNORECASE)
@@ -207,15 +207,15 @@ def _plot_heatmap_grid(
207
  title_feature_id: int | None = None,
208
  ) -> FigureSaveResult:
209
  """
210
- Рисует сетку overlay-хитмапов для одного feature_id.
211
 
212
- Параметры
213
  ----------
214
- imgs_list : список тензоров изображений (C, H, W)
215
- codes_tensor : тензор активаций (B, spatial, spatial, inner_dim)
216
- feature_id : индекс признака SAE
217
- image_paths : пути к PNG-файлам (для заголовков)
218
- img_size_inches: размер одной ячейки в дюймах
219
  """
220
  B = len(imgs_list)
221
  inner_concepts = codes_tensor.shape[-1]
@@ -268,15 +268,15 @@ def _plot_diff_grid(
268
  show_img: bool = True,
269
  ) -> FigureSaveResult:
270
  """
271
- Рисует сетку |distorted − original| для одного feature_id.
272
 
273
- Параметры
274
  ----------
275
- imgs_list : список тензоров искажённых изображений (C, H, W)
276
- orig_tensors : список тензоров оригиналов (или None, если файл не найден)
277
- feature_id : индекс признака SAE (для заголовка)
278
- image_paths : пути к PNG-файлам (для заголовков)
279
- img_size_inches: размер одной ячейки в дюймах
280
  """
281
  B = len(imgs_list)
282
  fig_diff, axes = plt.subplots(1, B, figsize=(img_size_inches * B, img_size_inches * 1.25))
@@ -302,37 +302,54 @@ def _plot_diff_grid(
302
  return _finalize_figure(fig_diff, save_path=save_path, show_img=show_img)
303
 
304
 
305
- def _plot_qground_mask_grid(
306
- mask_paths: List[Optional[str]],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  feature_id: int,
308
  image_paths: List[str],
 
 
309
  crop_size: int,
310
  img_size_inches: float = 4.0,
311
  meta_rows: Optional[List[Optional[pd.Series]]] = None,
312
  save_path: Optional[str] = None,
313
  show_img: bool = True,
314
  ) -> FigureSaveResult:
315
- B = len(mask_paths)
 
316
  fig, axes = plt.subplots(1, B, figsize=(img_size_inches * B, img_size_inches * 1.35))
317
  if B == 1:
318
  axes = [axes]
319
  if meta_rows is None:
320
  meta_rows = [None] * B
321
 
322
- for ax, mask_path, img_path, meta_row in zip(axes, mask_paths, image_paths, meta_rows):
323
- if mask_path is not None and Path(mask_path).exists():
324
- mask_rgb = np.asarray(Image.open(mask_path).convert('RGB'), dtype=np.uint8)
325
  ax.imshow(mask_rgb)
326
  height, width = mask_rgb.shape[:2]
327
- rect_width = float(crop_size)
328
- rect_height = float(crop_size)
329
- rect_x = (width - rect_width) / 2.0
330
- rect_y = (height - rect_height) / 2.0
331
  ax.add_patch(
332
  Rectangle(
333
  (rect_x, rect_y),
334
- rect_width,
335
- rect_height,
336
  fill=False,
337
  edgecolor='black',
338
  linewidth=2.0,
@@ -341,21 +358,64 @@ def _plot_qground_mask_grid(
341
  )
342
  _set_image_subplot_title(ax, img_path, meta_row)
343
  else:
344
- ax.text(0.5, 0.5, 'QGround mask not found', ha='center', va='center',
345
- transform=ax.transAxes, fontsize=9)
 
 
 
 
 
 
 
346
  ax.axis('off')
347
 
348
- handles = [Patch(facecolor='black', edgecolor='none', label='background')]
349
- for label_name, rgb in QGROUND_DISTORTION_TYPES.items():
350
- color = tuple((np.asarray(rgb, dtype=np.float32) / 255.0).tolist())
351
- handles.append(Patch(facecolor=color, edgecolor='none', label=label_name))
352
-
353
- _add_mask_legend(fig, handles)
354
- fig.suptitle(f'QGround annotation mask (feature {feature_id})', fontsize=12, x=0.5, ha='center')
355
  fig.tight_layout(rect=(0, 0.14, 1, 1))
356
  return _finalize_figure(fig, save_path=save_path, show_img=show_img)
357
 
358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  def _add_mask_legend(fig, handles: list[Patch], *, ncol: int = 3) -> None:
360
  fig.legend(
361
  handles=handles,
@@ -371,10 +431,23 @@ def _add_mask_legend(fig, handles: list[Patch], *, ncol: int = 3) -> None:
371
  )
372
 
373
 
374
- def srground_legend_handles(*, include_sr_artifact: bool = True) -> list[Patch]:
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  handles = [Patch(facecolor='black', edgecolor='none', label='background')]
376
  for dist_name, rgb in SRGROUND_DISTORTION_TYPES.items():
377
- if dist_name == 'sr_artifact' and not include_sr_artifact:
378
  continue
379
  color = tuple((np.asarray(rgb, dtype=np.float32) / 255.0).tolist())
380
  label = SRGROUND_LEGEND_LABELS.get(dist_name, dist_name)
@@ -392,7 +465,6 @@ def _plot_overlay_and_srground_mask_rows(
392
  meta_rows: Optional[List[Optional[pd.Series]]],
393
  *,
394
  title_feature_id: int,
395
- include_sr_artifact: bool,
396
  crop_size: int,
397
  save_path: Optional[str] = None,
398
  show_img: bool = False,
@@ -468,7 +540,7 @@ def _plot_overlay_and_srground_mask_rows(
468
 
469
  _add_mask_legend(
470
  fig,
471
- srground_legend_handles(include_sr_artifact=include_sr_artifact),
472
  )
473
  fig.suptitle(f'Feature {display_id}', fontsize=13, x=0.5, ha='center')
474
  fig.tight_layout(rect=(0, 0.16, 1, 0.96))
@@ -483,12 +555,12 @@ def render_top_feature_panel_srground(
483
  feature_id: int,
484
  *,
485
  patches_per_image: Optional[int] = None,
486
- crop_size: int = 224,
487
  img_size_inches: float = 3.5,
488
- include_sr_artifact: bool = True,
489
- datasets_root: str | None = None,
490
  ) -> bytes | None:
491
  """Two-row panel: SAE heatmap overlays and SRGround annotation masks with legend."""
 
492
  pil_images = [Image.open(p).convert('RGB') for p in image_paths]
493
  preprocess = transforms.Compose(
494
  [
@@ -511,20 +583,13 @@ def render_top_feature_panel_srground(
511
 
512
  meta_lookup = _build_meta_lookup(meta)
513
  meta_rows = [meta_lookup.get(int(img_idx)) for img_idx in image_indices]
514
- image_rels = [_image_rel_from_meta_row(row) for row in meta_rows]
515
- prom_map = srground_prominences_by_image_paths(
516
- [rel for rel in image_rels if rel],
517
- datasets_root=datasets_root,
518
- )
519
  mask_rgb_list = [
520
- srground_mask_rgb_for_meta_row(
521
  row,
522
- datasets_root=datasets_root,
523
- include_sr_artifact=include_sr_artifact,
524
  crop_size=crop_size,
525
- prominences=prom_map.get(image_rel) if image_rel else None,
526
  )
527
- for row, image_rel in zip(meta_rows, image_rels)
528
  ]
529
 
530
  matrix_col = features.column_for(int(feature_id))
@@ -537,7 +602,6 @@ def render_top_feature_panel_srground(
537
  img_size_inches,
538
  meta_rows,
539
  title_feature_id=int(feature_id),
540
- include_sr_artifact=include_sr_artifact,
541
  crop_size=crop_size,
542
  save_path=None,
543
  show_img=False,
@@ -554,39 +618,41 @@ def visualize_feature_heatmaps(
554
  patches_per_image: Optional[int] = None,
555
  crop_size: int = 224,
556
  img_size_inches: float = 4.0,
557
- show_diff: bool = True,
558
  save_dir: Optional[str] = None,
559
  file_prefix: str = '',
560
  show_img: bool = True,
 
 
561
  ) -> List[Dict[str, object]]:
562
  """
563
- Визуализирует тепловые карты SAE-признаков из предвычисленных sparse активаций.
564
 
565
- Для каждого feature_id отображается:
566
- - overlay-хитмап поверх искажённого изображения
567
- - (опционально) |distorted − original| попиксельно
568
 
569
- Параметры
570
  ----------
571
- meta : DataFrame с метаданными (image_idx, patch_idx, ...)
572
  features : CSR activations with global id per column
573
- image_indices : индексы изображений (image_idx) для визуализации
574
- image_paths : пути к соответствующим PNG-файлам
575
  feature_ids : global SAE feature ids to visualize
576
- patches_per_image: число патчей на изображение; если None — из patch_idx
577
- crop_size : размер кропа при кэшировании
578
- img_size_inches : размер одного изображения на фигуре (в дюймах)
579
- show_diff : показывать ли |distorted − original|
580
- save_dir : директория для сохранения PNG; если None — PNG в памяти (ключ ``bytes``)
581
- file_prefix : префикс имени файла (например, stage/agg)
582
- show_img : показывать ли фигуры inline
583
-
584
- Возвращает
585
- ----------
586
- List[Dict[str, object]] — артефакты с ключом ``path`` (диск) или ``bytes`` (память)
587
  """
588
  assert len(image_indices) == len(image_paths), \
589
- "image_indices и image_paths должны иметь одинаковую длину"
590
 
591
  codes = features.codes
592
  inner_dim = codes.shape[1]
@@ -596,8 +662,8 @@ def visualize_feature_heatmaps(
596
 
597
  spatial = int(math.isqrt(patches_per_image))
598
  assert spatial * spatial == patches_per_image, (
599
- f"Число патчей {patches_per_image} не является точным квадратом — "
600
- f"проверьте crop_size или слой модели"
601
  )
602
 
603
  preprocess = transforms.Compose(
@@ -618,11 +684,13 @@ def visualize_feature_heatmaps(
618
  patches_per_image=patches_per_image,
619
  crop_size=crop_size,
620
  img_size_inches=img_size_inches,
621
- show_diff=show_diff,
622
  save_dir=save_dir,
623
  file_prefix=file_prefix,
624
  show_img=show_img,
625
  preprocess=preprocess,
 
 
626
  )
627
 
628
 
@@ -636,12 +704,14 @@ def visualize_feature_heatmaps_from_images(
636
  patches_per_image: Optional[int] = None,
637
  crop_size: int = 224,
638
  img_size_inches: float = 4.0,
639
- show_diff: bool = True,
640
  save_dir: Optional[str] = None,
641
  file_prefix: str = '',
642
  show_img: bool = True,
643
  *,
644
  preprocess: Optional[object] = None,
 
 
645
  ) -> List[Dict[str, str]]:
646
  """Same as `visualize_feature_heatmaps`, but accepts already opened PIL images.
647
 
@@ -659,8 +729,8 @@ def visualize_feature_heatmaps_from_images(
659
 
660
  spatial = int(math.isqrt(patches_per_image))
661
  assert spatial * spatial == patches_per_image, (
662
- f"Число патчей {patches_per_image} не является точным квадратом — "
663
- f"проверьте crop_size или слой модели"
664
  )
665
 
666
  if preprocess is None:
@@ -677,9 +747,10 @@ def visualize_feature_heatmaps_from_images(
677
  meta_rows = [meta_lookup.get(int(img_idx)) for img_idx in image_indices]
678
 
679
  orig_tensors: List[Optional[torch.Tensor]] = []
680
- mask_paths: List[Optional[str]] = []
681
- has_qground_masks = False
682
- if show_diff:
 
683
  for img_name, meta_row in zip(image_names, meta_rows):
684
  orig_path = _get_original_path(img_name) or _meta_path_value(meta_row, 'original_img_path')
685
  if orig_path and Path(orig_path).exists():
@@ -687,10 +758,21 @@ def visualize_feature_heatmaps_from_images(
687
  else:
688
  orig_tensors.append(None)
689
 
690
- mask_path = _meta_path_value(meta_row, 'mask_path')
691
- if mask_path and Path(mask_path).exists():
692
- has_qground_masks = True
693
- mask_paths.append(mask_path)
 
 
 
 
 
 
 
 
 
 
 
694
 
695
  image_idx_arr = meta['image_idx'].values
696
  codes_list = []
@@ -728,43 +810,44 @@ def visualize_feature_heatmaps_from_images(
728
  feature_id=int(global_feature_id),
729
  saved=saved_overlay,
730
  )
731
- if show_diff and any(t is not None for t in orig_tensors):
732
- diff_name = f'{safe_prefix}feature_{global_feature_id}_diff.png'
733
- diff_path = str(save_root / diff_name) if save_root is not None else None
734
- saved_diff = _plot_diff_grid(
735
- imgs_list,
736
- orig_tensors,
737
  global_feature_id,
738
  image_names,
739
- img_size_inches,
 
740
  meta_rows=meta_rows,
741
- save_path=diff_path,
 
742
  show_img=show_img,
743
  )
744
  _append_heatmap_artifact(
745
  artifacts,
746
- kind='diff',
747
  feature_id=int(global_feature_id),
748
- saved=saved_diff,
749
  )
750
- elif show_diff and has_qground_masks:
751
- mask_name = f'{safe_prefix}feature_{global_feature_id}_mask.png'
752
- mask_path = str(save_root / mask_name) if save_root is not None else None
753
- saved_mask = _plot_qground_mask_grid(
754
- mask_paths,
 
755
  global_feature_id,
756
  image_names,
 
757
  meta_rows=meta_rows,
758
- img_size_inches=img_size_inches,
759
- crop_size=crop_size,
760
- save_path=mask_path,
761
  show_img=show_img,
762
  )
763
  _append_heatmap_artifact(
764
  artifacts,
765
- kind='mask',
766
  feature_id=int(global_feature_id),
767
- saved=saved_mask,
768
  )
769
 
770
  return artifacts
 
1
  """
2
+ Visualization of SAE feature heatmaps overlaid on KADID-10k images.
3
  """
4
 
5
  import io
 
27
  QGROUND_DISTORTION_TYPES,
28
  SRGROUND_DISTORTION_TYPES,
29
  SRGROUND_LEGEND_LABELS,
30
+ annotation_mask_rgb_for_meta_row,
31
  available_distortions,
32
  distortion_types_mapping,
33
+ get_srground_rgb_mask,
34
+ infer_spatial_mask_dataset,
 
35
  )
36
 
37
  # Show per-image filenames (and distortion subtitles where enabled) under heatmap panels.
38
  SHOW_HEATMAP_IMAGE_TITLES = False
39
 
40
+ MASK_LEGEND_FONTSIZE = 14
41
  MASK_LEGEND_HANDLE_HEIGHT = 1.15
42
  MASK_LEGEND_HANDLE_LENGTH = 1.55
43
 
44
 
45
  def _get_distortion_info(img_path: str):
46
  """
47
+ From a KADID filename (I04_07_05.png), return
48
+ (distortion_name, distortion_group) or (None, None) for originals.
49
  """
50
  name = Path(img_path).name
51
  m = _re.match(r'I\d+_(\d+)_(\d+)\.png$', name, _re.IGNORECASE)
 
59
 
60
  def _get_original_path(distorted_path: str) -> Optional[str]:
61
  """
62
+ From a KADID distorted image path (I04_07_05.png),
63
+ return the path to the original (I04.png).
64
  """
65
  p = Path(distorted_path)
66
  match = _re.match(r'(I\d+)_\d+_\d+\.png$', p.name, _re.IGNORECASE)
 
207
  title_feature_id: int | None = None,
208
  ) -> FigureSaveResult:
209
  """
210
+ Draw a grid of overlay heatmaps for a single feature_id.
211
 
212
+ Parameters
213
  ----------
214
+ imgs_list : list of image tensors (C, H, W)
215
+ codes_tensor : activation tensor (B, spatial, spatial, inner_dim)
216
+ feature_id : SAE feature index
217
+ image_paths : paths to PNG files (for titles)
218
+ img_size_inches: size of one cell in inches
219
  """
220
  B = len(imgs_list)
221
  inner_concepts = codes_tensor.shape[-1]
 
268
  show_img: bool = True,
269
  ) -> FigureSaveResult:
270
  """
271
+ Draw a grid of |distorted − original| for a single feature_id.
272
 
273
+ Parameters
274
  ----------
275
+ imgs_list : list of distorted image tensors (C, H, W)
276
+ orig_tensors : list of original tensors (or None if the file is missing)
277
+ feature_id : SAE feature index (for the title)
278
+ image_paths : paths to PNG files (for titles)
279
+ img_size_inches: size of one cell in inches
280
  """
281
  B = len(imgs_list)
282
  fig_diff, axes = plt.subplots(1, B, figsize=(img_size_inches * B, img_size_inches * 1.25))
 
302
  return _finalize_figure(fig_diff, save_path=save_path, show_img=show_img)
303
 
304
 
305
+ def annotation_mask_legend_handles(
306
+ dataset: str,
307
+ *,
308
+ mask_rgb_list: Optional[List[Optional[np.ndarray]]] = None,
309
+ ) -> list[Patch]:
310
+ if dataset == 'SRGround':
311
+ return srground_legend_handles(mask_rgb_list)
312
+ if dataset == 'QGround':
313
+ handles = [Patch(facecolor='black', edgecolor='none', label='background')]
314
+ for label_name, rgb in QGROUND_DISTORTION_TYPES.items():
315
+ color = tuple((np.asarray(rgb, dtype=np.float32) / 255.0).tolist())
316
+ handles.append(Patch(facecolor=color, edgecolor='none', label=label_name))
317
+ return handles
318
+ return [Patch(facecolor='black', edgecolor='none', label='background')]
319
+
320
+
321
+ def _plot_annotation_mask_grid(
322
+ mask_rgb_list: List[Optional[np.ndarray]],
323
  feature_id: int,
324
  image_paths: List[str],
325
+ *,
326
+ dataset: str,
327
  crop_size: int,
328
  img_size_inches: float = 4.0,
329
  meta_rows: Optional[List[Optional[pd.Series]]] = None,
330
  save_path: Optional[str] = None,
331
  show_img: bool = True,
332
  ) -> FigureSaveResult:
333
+ """Plot GT annotation masks (QGround PNG or SRGround synthesized RGB) in one row."""
334
+ B = len(mask_rgb_list)
335
  fig, axes = plt.subplots(1, B, figsize=(img_size_inches * B, img_size_inches * 1.35))
336
  if B == 1:
337
  axes = [axes]
338
  if meta_rows is None:
339
  meta_rows = [None] * B
340
 
341
+ for ax, mask_rgb, img_path, meta_row in zip(axes, mask_rgb_list, image_paths, meta_rows):
342
+ if mask_rgb is not None:
 
343
  ax.imshow(mask_rgb)
344
  height, width = mask_rgb.shape[:2]
345
+ rect_size = float(crop_size)
346
+ rect_x = (width - rect_size) / 2.0
347
+ rect_y = (height - rect_size) / 2.0
 
348
  ax.add_patch(
349
  Rectangle(
350
  (rect_x, rect_y),
351
+ rect_size,
352
+ rect_size,
353
  fill=False,
354
  edgecolor='black',
355
  linewidth=2.0,
 
358
  )
359
  _set_image_subplot_title(ax, img_path, meta_row)
360
  else:
361
+ ax.text(
362
+ 0.5,
363
+ 0.5,
364
+ f'{dataset} annotation mask not found',
365
+ ha='center',
366
+ va='center',
367
+ transform=ax.transAxes,
368
+ fontsize=9,
369
+ )
370
  ax.axis('off')
371
 
372
+ _add_mask_legend(
373
+ fig,
374
+ annotation_mask_legend_handles(dataset, mask_rgb_list=mask_rgb_list),
375
+ )
376
+ fig.suptitle(f'{dataset} annotation mask (feature {feature_id})', fontsize=12, x=0.5, ha='center')
 
 
377
  fig.tight_layout(rect=(0, 0.14, 1, 1))
378
  return _finalize_figure(fig, save_path=save_path, show_img=show_img)
379
 
380
 
381
+ def _plot_qground_mask_grid(
382
+ mask_paths: List[Optional[str]],
383
+ feature_id: int,
384
+ image_paths: List[str],
385
+ crop_size: int,
386
+ img_size_inches: float = 4.0,
387
+ meta_rows: Optional[List[Optional[pd.Series]]] = None,
388
+ save_path: Optional[str] = None,
389
+ show_img: bool = True,
390
+ *,
391
+ dataset_root: str | None = None,
392
+ ) -> FigureSaveResult:
393
+ """Backward-compatible wrapper around :func:`_plot_annotation_mask_grid`."""
394
+ if meta_rows is None:
395
+ meta_rows = [None] * len(mask_paths)
396
+ if dataset_root is None:
397
+ raise ValueError('dataset_root is required for QGround mask visualization')
398
+ mask_rgb_list = [
399
+ annotation_mask_rgb_for_meta_row(
400
+ meta_row,
401
+ dataset_root=dataset_root,
402
+ dataset='QGround',
403
+ )
404
+ for meta_row in meta_rows
405
+ ]
406
+ return _plot_annotation_mask_grid(
407
+ mask_rgb_list,
408
+ feature_id,
409
+ image_paths,
410
+ dataset='QGround',
411
+ crop_size=crop_size,
412
+ img_size_inches=img_size_inches,
413
+ meta_rows=meta_rows,
414
+ save_path=save_path,
415
+ show_img=show_img,
416
+ )
417
+
418
+
419
  def _add_mask_legend(fig, handles: list[Patch], *, ncol: int = 3) -> None:
420
  fig.legend(
421
  handles=handles,
 
431
  )
432
 
433
 
434
+ def srground_legend_handles(
435
+ mask_rgb_list: Optional[List[Optional[np.ndarray]]] = None,
436
+ ) -> list[Patch]:
437
+ """Legend entries for distortion classes present in plotted SRGround RGB masks."""
438
+ present: set[str] | None = None
439
+ if mask_rgb_list is not None:
440
+ present = set()
441
+ for mask_rgb in mask_rgb_list:
442
+ if mask_rgb is None:
443
+ continue
444
+ for dist_name, rgb in SRGROUND_DISTORTION_TYPES.items():
445
+ if np.isclose(mask_rgb, rgb, rtol=0.2, atol=20).any():
446
+ present.add(dist_name)
447
+
448
  handles = [Patch(facecolor='black', edgecolor='none', label='background')]
449
  for dist_name, rgb in SRGROUND_DISTORTION_TYPES.items():
450
+ if present is not None and dist_name not in present:
451
  continue
452
  color = tuple((np.asarray(rgb, dtype=np.float32) / 255.0).tolist())
453
  label = SRGROUND_LEGEND_LABELS.get(dist_name, dist_name)
 
465
  meta_rows: Optional[List[Optional[pd.Series]]],
466
  *,
467
  title_feature_id: int,
 
468
  crop_size: int,
469
  save_path: Optional[str] = None,
470
  show_img: bool = False,
 
540
 
541
  _add_mask_legend(
542
  fig,
543
+ srground_legend_handles(mask_rgb_list),
544
  )
545
  fig.suptitle(f'Feature {display_id}', fontsize=13, x=0.5, ha='center')
546
  fig.tight_layout(rect=(0, 0.16, 1, 0.96))
 
555
  feature_id: int,
556
  *,
557
  patches_per_image: Optional[int] = None,
558
+ crop_size: int = 512,
559
  img_size_inches: float = 3.5,
560
+ dataset_root: str,
 
561
  ) -> bytes | None:
562
  """Two-row panel: SAE heatmap overlays and SRGround annotation masks with legend."""
563
+ crop_size = 512 # TODO: fix later
564
  pil_images = [Image.open(p).convert('RGB') for p in image_paths]
565
  preprocess = transforms.Compose(
566
  [
 
583
 
584
  meta_lookup = _build_meta_lookup(meta)
585
  meta_rows = [meta_lookup.get(int(img_idx)) for img_idx in image_indices]
 
 
 
 
 
586
  mask_rgb_list = [
587
+ get_srground_rgb_mask(
588
  row,
589
+ dataset_root=dataset_root,
 
590
  crop_size=crop_size,
 
591
  )
592
+ for row in meta_rows
593
  ]
594
 
595
  matrix_col = features.column_for(int(feature_id))
 
602
  img_size_inches,
603
  meta_rows,
604
  title_feature_id=int(feature_id),
 
605
  crop_size=crop_size,
606
  save_path=None,
607
  show_img=False,
 
618
  patches_per_image: Optional[int] = None,
619
  crop_size: int = 224,
620
  img_size_inches: float = 4.0,
621
+ show_mask: bool = True,
622
  save_dir: Optional[str] = None,
623
  file_prefix: str = '',
624
  show_img: bool = True,
625
+ dataset_root: Optional[str] = None,
626
+ dataset: Optional[str] = None,
627
  ) -> List[Dict[str, object]]:
628
  """
629
+ Visualize SAE feature heatmaps from precomputed sparse activations.
630
 
631
+ For each feature_id:
632
+ - overlay heatmap on the distorted image
633
+ - (optional) second row: GT mask (QGround/SRGround) or |distorted − original|
634
 
635
+ Parameters
636
  ----------
637
+ meta : metadata DataFrame (image_idx, patch_idx, ...)
638
  features : CSR activations with global id per column
639
+ image_indices : image indices (image_idx) to visualize
640
+ image_paths : paths to the corresponding PNG files
641
  feature_ids : global SAE feature ids to visualize
642
+ patches_per_image: number of patches per image; if None — inferred from patch_idx
643
+ crop_size : crop size used during caching
644
+ img_size_inches : size of one image on the figure (in inches)
645
+ show_mask : second row — GT mask or |distorted − original| if no mask is available
646
+ save_dir : directory for saving PNGs; if None — PNGs in memory (``bytes`` key)
647
+ file_prefix : filename prefix (e.g. stage/agg)
648
+ show_img : whether to display figures inline
649
+
650
+ Returns
651
+ -------
652
+ List[Dict[str, object]] — artifacts with ``path`` (disk) or ``bytes`` (memory) key
653
  """
654
  assert len(image_indices) == len(image_paths), \
655
+ "image_indices and image_paths must have the same length"
656
 
657
  codes = features.codes
658
  inner_dim = codes.shape[1]
 
662
 
663
  spatial = int(math.isqrt(patches_per_image))
664
  assert spatial * spatial == patches_per_image, (
665
+ f"Number of patches {patches_per_image} is not a perfect square — "
666
+ f"check crop_size or model layer"
667
  )
668
 
669
  preprocess = transforms.Compose(
 
684
  patches_per_image=patches_per_image,
685
  crop_size=crop_size,
686
  img_size_inches=img_size_inches,
687
+ show_mask=show_mask,
688
  save_dir=save_dir,
689
  file_prefix=file_prefix,
690
  show_img=show_img,
691
  preprocess=preprocess,
692
+ dataset_root=dataset_root,
693
+ dataset=dataset,
694
  )
695
 
696
 
 
704
  patches_per_image: Optional[int] = None,
705
  crop_size: int = 224,
706
  img_size_inches: float = 4.0,
707
+ show_mask: bool = True,
708
  save_dir: Optional[str] = None,
709
  file_prefix: str = '',
710
  show_img: bool = True,
711
  *,
712
  preprocess: Optional[object] = None,
713
+ dataset_root: Optional[str] = None,
714
+ dataset: Optional[str] = None,
715
  ) -> List[Dict[str, str]]:
716
  """Same as `visualize_feature_heatmaps`, but accepts already opened PIL images.
717
 
 
729
 
730
  spatial = int(math.isqrt(patches_per_image))
731
  assert spatial * spatial == patches_per_image, (
732
+ f"Number of patches {patches_per_image} is not a perfect square — "
733
+ f"check crop_size or model layer"
734
  )
735
 
736
  if preprocess is None:
 
747
  meta_rows = [meta_lookup.get(int(img_idx)) for img_idx in image_indices]
748
 
749
  orig_tensors: List[Optional[torch.Tensor]] = []
750
+ mask_rgb_list: List[Optional[np.ndarray]] = []
751
+ has_annotation_masks = False
752
+ mask_dataset = dataset
753
+ if show_mask:
754
  for img_name, meta_row in zip(image_names, meta_rows):
755
  orig_path = _get_original_path(img_name) or _meta_path_value(meta_row, 'original_img_path')
756
  if orig_path and Path(orig_path).exists():
 
758
  else:
759
  orig_tensors.append(None)
760
 
761
+ row_dataset = mask_dataset or infer_spatial_mask_dataset(meta_row)
762
+ mask_rgb = None
763
+ if dataset_root is not None and row_dataset in {'QGround', 'SRGround'}:
764
+ mask_rgb = annotation_mask_rgb_for_meta_row(
765
+ meta_row,
766
+ dataset_root=dataset_root,
767
+ dataset=row_dataset,
768
+ crop_size=crop_size,
769
+ full_frame=True,
770
+ )
771
+ if mask_rgb is not None:
772
+ has_annotation_masks = True
773
+ mask_rgb_list.append(mask_rgb)
774
+ if mask_dataset is None and meta_rows:
775
+ mask_dataset = infer_spatial_mask_dataset(meta_rows[0]) or 'QGround'
776
 
777
  image_idx_arr = meta['image_idx'].values
778
  codes_list = []
 
810
  feature_id=int(global_feature_id),
811
  saved=saved_overlay,
812
  )
813
+ if show_mask and has_annotation_masks:
814
+ mask_name = f'{safe_prefix}feature_{global_feature_id}_mask.png'
815
+ mask_save_path = str(save_root / mask_name) if save_root is not None else None
816
+ saved_mask = _plot_annotation_mask_grid(
817
+ mask_rgb_list,
 
818
  global_feature_id,
819
  image_names,
820
+ dataset=str(mask_dataset or 'QGround'),
821
+ crop_size=crop_size,
822
  meta_rows=meta_rows,
823
+ img_size_inches=img_size_inches,
824
+ save_path=mask_save_path,
825
  show_img=show_img,
826
  )
827
  _append_heatmap_artifact(
828
  artifacts,
829
+ kind='mask',
830
  feature_id=int(global_feature_id),
831
+ saved=saved_mask,
832
  )
833
+ elif show_mask and any(t is not None for t in orig_tensors):
834
+ diff_name = f'{safe_prefix}feature_{global_feature_id}_diff.png'
835
+ diff_path = str(save_root / diff_name) if save_root is not None else None
836
+ saved_diff = _plot_diff_grid(
837
+ imgs_list,
838
+ orig_tensors,
839
  global_feature_id,
840
  image_names,
841
+ img_size_inches,
842
  meta_rows=meta_rows,
843
+ save_path=diff_path,
 
 
844
  show_img=show_img,
845
  )
846
  _append_heatmap_artifact(
847
  artifacts,
848
+ kind='diff',
849
  feature_id=int(global_feature_id),
850
+ saved=saved_diff,
851
  )
852
 
853
  return artifacts
analysis/viz/vis_scatter.py CHANGED
@@ -1,6 +1,6 @@
1
  """
2
- Scatter-plot активаций SAE-признаков, часть функций тоже из PatchSAE:
3
- log(sparsity) × log(mean_acts), окрашенные по энтропии меток искажений.
4
  """
5
 
6
  from pathlib import Path
@@ -56,20 +56,20 @@ def get_top_k_patches(
56
  label_col: str = 'dist_group',
57
  ) -> Tuple[torch.Tensor, torch.Tensor, List[str]]:
58
  """
59
- Для каждого признака SAE выбирает top-K патчей с наибольшими активациями.
60
 
61
- Параметры
62
  ----------
63
- codes : CSR матрица (n_patches, n_features)
64
- meta : DataFrame с колонкой label_col
65
- top_k : число патчей; None — все ненулевые (с паддингом до max nnz)
66
- label_col : 'dist_group' или 'dist_type'
67
 
68
- Возвращает
69
- ----------
70
  top_val : FloatTensor (n_features, effective_k)
71
  top_label : LongTensor (n_features, effective_k)
72
- label_names : List[str] — расшифровка числовых меток
73
  """
74
  n_patches, n_features = codes.shape
75
 
@@ -118,19 +118,19 @@ def prepare_scatter_stats(
118
  group_filter: Optional[str] = None,
119
  ) -> Tuple[Dict[str, torch.Tensor], List[str]]:
120
  """
121
- Собирает словарь статистик, совместимый с get_stats_scatter_plot.
122
-
123
- Параметры
124
- ----------
125
- codes : CSR матрица (n_patches, n_features)
126
- meta : DataFrame с метаданными
127
- top_k : число top-патчей для расчёта энтропии
128
- label_col : 'dist_group' или 'dist_type'
129
- group_filter : если задана — фильтрует строки по dist_group == group_filter
130
 
131
- Возвращает
132
  ----------
133
- stats : dict с ключами 'sparsity', 'mean_acts', 'top_entropy' — FloatTensors
 
 
 
 
 
 
 
 
134
  label_names : List[str]
135
  """
136
  if group_filter is not None:
@@ -198,10 +198,10 @@ def _compute_color_metric_values(color_metric: str,
198
  *,
199
  dataset: str) -> torch.Tensor:
200
  '''
201
- Вычисляет вектор значений метрики для окрашивания точек на scatter plot.
202
- Поддерживает 'entropy', 'roc_auc', 'iou', 'precision' и 'recall'.
203
- Для табличных метрик возвращает вектор длины n_features,
204
- агрегируя значения по категориям из label_col.
205
  '''
206
  color_metric = str(color_metric).strip().lower()
207
  if color_metric == 'entropy':
 
1
  """
2
+ Scatter plot of SAE feature activations; some functions are also from PatchSAE:
3
+ log(sparsity) × log(mean_acts), colored by distortion-label entropy.
4
  """
5
 
6
  from pathlib import Path
 
56
  label_col: str = 'dist_group',
57
  ) -> Tuple[torch.Tensor, torch.Tensor, List[str]]:
58
  """
59
+ For each SAE feature, select the top-K patches with the largest activations.
60
 
61
+ Parameters
62
  ----------
63
+ codes : CSR matrix (n_patches, n_features)
64
+ meta : DataFrame with a label_col column
65
+ top_k : number of patches; None — all nonzero (padded to max nnz)
66
+ label_col : 'dist_group' or 'dist_type'
67
 
68
+ Returns
69
+ -------
70
  top_val : FloatTensor (n_features, effective_k)
71
  top_label : LongTensor (n_features, effective_k)
72
+ label_names : List[str] — mapping for numeric labels
73
  """
74
  n_patches, n_features = codes.shape
75
 
 
118
  group_filter: Optional[str] = None,
119
  ) -> Tuple[Dict[str, torch.Tensor], List[str]]:
120
  """
121
+ Build a stats dict compatible with get_stats_scatter_plot.
 
 
 
 
 
 
 
 
122
 
123
+ Parameters
124
  ----------
125
+ codes : CSR matrix (n_patches, n_features)
126
+ meta : metadata DataFrame
127
+ top_k : number of top patches for entropy computation
128
+ label_col : 'dist_group' or 'dist_type'
129
+ group_filter : if set, filter rows by dist_group == group_filter
130
+
131
+ Returns
132
+ -------
133
+ stats : dict with keys 'sparsity', 'mean_acts', 'top_entropy' — FloatTensors
134
  label_names : List[str]
135
  """
136
  if group_filter is not None:
 
198
  *,
199
  dataset: str) -> torch.Tensor:
200
  '''
201
+ Compute a metric vector for coloring scatter-plot points.
202
+ Supports 'entropy', 'roc_auc', 'iou', 'precision', and 'recall'.
203
+ For tabular metrics, returns a vector of length n_features,
204
+ aggregating values across categories from label_col.
205
  '''
206
  color_metric = str(color_metric).strip().lower()
207
  if color_metric == 'entropy':
assets/style.css CHANGED
@@ -712,16 +712,20 @@ body {
712
  }
713
 
714
  .feature-jump-input .Select-control,
 
715
  .feature-jump-input input {
716
  min-height: 42px;
717
  }
718
 
719
  /* Hide +/- steppers in feature id number input (browser-native spinners). */
 
720
  .feature-jump-input input[type="number"] {
721
  -moz-appearance: textfield;
722
  appearance: textfield;
723
  }
724
 
 
 
725
  .feature-jump-input input[type="number"]::-webkit-outer-spin-button,
726
  .feature-jump-input input[type="number"]::-webkit-inner-spin-button {
727
  -webkit-appearance: none;
 
712
  }
713
 
714
  .feature-jump-input .Select-control,
715
+ input.feature-jump-input,
716
  .feature-jump-input input {
717
  min-height: 42px;
718
  }
719
 
720
  /* Hide +/- steppers in feature id number input (browser-native spinners). */
721
+ input.feature-jump-input[type="number"],
722
  .feature-jump-input input[type="number"] {
723
  -moz-appearance: textfield;
724
  appearance: textfield;
725
  }
726
 
727
+ input.feature-jump-input[type="number"]::-webkit-outer-spin-button,
728
+ input.feature-jump-input[type="number"]::-webkit-inner-spin-button,
729
  .feature-jump-input input[type="number"]::-webkit-outer-spin-button,
730
  .feature-jump-input input[type="number"]::-webkit-inner-spin-button {
731
  -webkit-appearance: none;
dashboard/__init__.py CHANGED
@@ -1,3 +1,13 @@
1
  """Dash application helpers for xIQA."""
2
 
3
- from .model_catalog import MODEL_FAMILIES, ModelRecord, discover_models_for_metric, discover_supported_datasets
 
 
 
 
 
 
 
 
 
 
 
1
  """Dash application helpers for xIQA."""
2
 
3
+ from .model_catalog import (
4
+ MODEL_FAMILIES,
5
+ ModelRecord,
6
+ checkpoint_cache_dir,
7
+ checkpoint_dataset_cache_paths,
8
+ discover_models_for_metric,
9
+ discover_supported_datasets,
10
+ get_model_record,
11
+ require_model_record,
12
+ selector_cache_files_for_model,
13
+ )
dashboard/image_utils.py CHANGED
@@ -7,7 +7,7 @@ from typing import List
7
  from analysis.datasets import resolve_dataset_image_path
8
  from analysis.utils import get_top_images_for_feature, get_top_images_for_feature_by_iou
9
  from analysis.viz.vis_heatmaps import render_top_feature_panel_srground, visualize_feature_heatmaps
10
- from dashboard.model_catalog import discover_models_for_metric, load_and_filter_model_activations
11
 
12
  ROOT = Path(__file__).resolve().parents[1]
13
 
@@ -52,12 +52,12 @@ def get_top_feature_overlays(
52
  """
53
  ranking_mode = str(ranking_mode or "iou")
54
 
55
- record = next((r for r in discover_models_for_metric(metric) if r.model_key == model_key), None)
56
  if record is None:
57
  return []
58
 
59
  try:
60
- filtered = load_and_filter_model_activations(Path(record.model_path), selection_dataset)
61
  except Exception as exc:
62
  print(f"Error occurred while loading activations for {selection_dataset!r}: {exc}")
63
  return []
@@ -92,7 +92,6 @@ def get_top_feature_overlays(
92
  return []
93
 
94
  first_rows = meta.groupby("image_idx", sort=False).first()
95
- datasets_root = str(dataset_root) if dataset_root is not None else None
96
  image_paths = []
97
  for idx in top_image_idxs:
98
  try:
@@ -115,7 +114,7 @@ def get_top_feature_overlays(
115
  resolved = resolve_dataset_image_path(
116
  selection_dataset,
117
  img_path,
118
- datasets_root=datasets_root,
119
  )
120
  image_paths.append(str(resolved))
121
 
@@ -135,8 +134,7 @@ def get_top_feature_overlays(
135
  patches_per_image=None,
136
  crop_size=224,
137
  img_size_inches=3.5,
138
- include_sr_artifact=INCLUDE_SRGROUND_SR_ARTIFACT,
139
- datasets_root=str(dataset_root) if dataset_root is not None else None,
140
  )
141
  if panel_bytes is None:
142
  return []
@@ -158,7 +156,7 @@ def get_top_feature_overlays(
158
  patches_per_image=None,
159
  crop_size=224,
160
  img_size_inches=3.5,
161
- show_diff=False,
162
  save_dir=None,
163
  file_prefix="topmax",
164
  show_img=False,
 
7
  from analysis.datasets import resolve_dataset_image_path
8
  from analysis.utils import get_top_images_for_feature, get_top_images_for_feature_by_iou
9
  from analysis.viz.vis_heatmaps import render_top_feature_panel_srground, visualize_feature_heatmaps
10
+ from dashboard.model_catalog import get_model_record, load_and_filter_model_activations
11
 
12
  ROOT = Path(__file__).resolve().parents[1]
13
 
 
52
  """
53
  ranking_mode = str(ranking_mode or "iou")
54
 
55
+ record = get_model_record(metric, model_key)
56
  if record is None:
57
  return []
58
 
59
  try:
60
+ filtered = load_and_filter_model_activations(record.checkpoint_path, selection_dataset)
61
  except Exception as exc:
62
  print(f"Error occurred while loading activations for {selection_dataset!r}: {exc}")
63
  return []
 
92
  return []
93
 
94
  first_rows = meta.groupby("image_idx", sort=False).first()
 
95
  image_paths = []
96
  for idx in top_image_idxs:
97
  try:
 
114
  resolved = resolve_dataset_image_path(
115
  selection_dataset,
116
  img_path,
117
+ datasets_root=str(dataset_root) if dataset_root is not None else None,
118
  )
119
  image_paths.append(str(resolved))
120
 
 
134
  patches_per_image=None,
135
  crop_size=224,
136
  img_size_inches=3.5,
137
+ dataset_root=dataset_root,
 
138
  )
139
  if panel_bytes is None:
140
  return []
 
156
  patches_per_image=None,
157
  crop_size=224,
158
  img_size_inches=3.5,
159
+ show_mask=False,
160
  save_dir=None,
161
  file_prefix="topmax",
162
  show_img=False,
dashboard/model_catalog.py CHANGED
@@ -1,18 +1,25 @@
1
  from __future__ import annotations
2
 
3
  import ast
 
4
  import os
5
  import re
6
  from dataclasses import dataclass
7
  from pathlib import Path
8
- from typing import Any, Iterable, Mapping
9
 
10
  import numpy as np
11
  from functools import lru_cache
12
  import pandas as pd
13
  import scipy.sparse as sp
14
 
15
- from analysis.cache_utils import load_cache
 
 
 
 
 
 
16
  from analysis.features.feature_filters import (
17
  build_filter,
18
  )
@@ -21,7 +28,7 @@ from analysis.features.feature_indexing import FeatureMatrix
21
 
22
  PROJECT_ROOT = Path(__file__).resolve().parents[1]
23
  DEFAULT_MODEL_STORAGE = PROJECT_ROOT / "logs"
24
- MODEL_FAMILIES = ("MANIQA", "ARNIQA", "LIQE")
25
  METRIC_DESCRIPTIONS: dict[str, tuple[str, ...]] = {
26
  "MANIQA": (
27
  "No-reference IQA with two Swin Transformer stages: stage 1 (768-d) and stage 2 (384-d). "
@@ -41,8 +48,6 @@ METRIC_DESCRIPTIONS: dict[str, tuple[str, ...]] = {
41
  "l0 is the validation mean count of active SAE features per image (higher = less sparse).",
42
  ),
43
  }
44
- SUPPORTED_DATASETS = ("SRGround", "local_kadid", "kadid10k")
45
-
46
  _FAMILY_ROOT_CANDIDATES = {
47
  "MANIQA": ("maniqa_logs", "MANIQA_logs", "maniqa"),
48
  "ARNIQA": ("arniqa_logs", "ARNIQA_logs", "arniqa"),
@@ -63,7 +68,8 @@ _MODEL_DIR_PATTERN = re.compile(
63
  class ModelRecord:
64
  family: str
65
  model_name: str
66
- model_path: str
 
67
  experiment_num: int
68
  layer_num: int | None
69
  lambda_token: str | None
@@ -234,10 +240,27 @@ def discover_models_for_metric(metric: str, model_storage: Path | None = None) -
234
  )
235
 
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  @lru_cache(maxsize=32)
238
  def _discover_models_for_metric_cached(metric_name: str, storage_root_str: str) -> tuple[ModelRecord, ...]:
239
  storage_root = Path(storage_root_str)
240
  family_root = resolve_family_root(metric_name, model_storage=storage_root)
 
241
  if not family_root.exists():
242
  return ()
243
 
@@ -259,11 +282,16 @@ def _discover_models_for_metric_cached(metric_name: str, storage_root_str: str)
259
  except Exception:
260
  l0_loss_value = None
261
 
 
 
 
 
262
  records.append(
263
  ModelRecord(
264
  family=metric_name,
265
  model_name=str(model_dir.name),
266
- model_path=str(model_dir), # <-- FIX
 
267
  experiment_num=int(parsed_name["experiment_num"] or -1),
268
  layer_num=parsed_name["layer_num"],
269
  lambda_token=parsed_name["lambda_token"],
@@ -276,7 +304,33 @@ def _discover_models_for_metric_cached(metric_name: str, storage_root_str: str)
276
 
277
  records.sort(key=lambda r: (r.experiment_num, r.model_name), reverse=True)
278
 
279
- return tuple(records) # <-- FIX (immutable)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
 
281
 
282
  def build_model_option_label(record: ModelRecord) -> str:
@@ -297,25 +351,38 @@ def summarize_model_record(record: ModelRecord) -> str:
297
  return "\n • ".join(params_split)
298
 
299
 
300
- def discover_selector_cache_files(model_path: Path, dataset: str) -> dict[str, Path]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  dataset_name = str(dataset).strip()
302
  if not dataset_name:
303
  return {}
304
 
 
 
 
 
 
 
 
305
  cache_files: dict[str, Path] = {}
306
- # For some datasets we only want patch-level metrics
307
- patch_only = dataset_name.lower() in ("local_kadid", "srground")
308
- for cache_root_name in ("cache", "activation_cache"):
309
- for cache_dir in sorted(model_path.glob(f"**/{cache_root_name}/{dataset_name}")):
310
- if not cache_dir.is_dir():
311
- continue
312
- for parquet_path in sorted(cache_dir.glob("*.parquet")):
313
- stem = parquet_path.stem
314
- if patch_only and "_patch" not in stem:
315
- # skip non-patch tables for these datasets
316
- continue
317
- cache_files.setdefault(stem, parquet_path)
318
-
319
  return cache_files
320
 
321
 
@@ -326,7 +393,7 @@ def summarize_selector_cache_file(cache_path: Path) -> tuple[float | None, float
326
  aggregate those per-feature scores across all features. Returns
327
  (max, mean, variance, min) computed over per-feature scores.
328
  """
329
- feature_table = load_selector_cache_feature_table(cache_path)
330
  if feature_table.empty:
331
  return None, None, None, None
332
 
@@ -338,7 +405,8 @@ def summarize_selector_cache_file(cache_path: Path) -> tuple[float | None, float
338
  return float(arr.max()), float(arr.mean()), float(arr.var()), float(arr.min())
339
 
340
 
341
- def load_selector_cache_feature_table(cache_path: Path) -> pd.DataFrame:
 
342
  """Return ordered per-feature selector scores with their feature ids.
343
 
344
  The returned frame contains:
@@ -346,18 +414,12 @@ def load_selector_cache_feature_table(cache_path: Path) -> pd.DataFrame:
346
  - feature_score: max absolute score for that feature column
347
  - feature_rank: 1-based rank after sorting by score descending, then feature id ascending
348
  """
349
- # Delegate to cached loader keyed by the cache path string to avoid
350
- # repeated expensive parquet reads when users rapidly switch features.
351
- return _load_selector_cache_feature_table_cached(str(cache_path))
352
-
353
-
354
- @lru_cache(maxsize=256)
355
- def _load_selector_cache_feature_table_cached(cache_path_str: str) -> pd.DataFrame:
356
  columns = ["feature_id", "feature_score", "feature_rank"]
357
- cache_path = Path(cache_path_str)
358
- if not cache_path.exists():
359
  return pd.DataFrame(columns=columns)
360
- table = pd.read_parquet(cache_path)
 
361
  numeric = table.select_dtypes(include=["number"])
362
  if numeric.empty:
363
  return pd.DataFrame(columns=columns)
@@ -389,7 +451,7 @@ def load_selector_cache_values(cache_path: Path) -> np.ndarray:
389
 
390
  Each value is the max absolute value for one numeric feature column.
391
  """
392
- feature_table = load_selector_cache_feature_table(cache_path)
393
  if feature_table.empty:
394
  return np.array([], dtype=float)
395
 
@@ -397,41 +459,47 @@ def load_selector_cache_values(cache_path: Path) -> np.ndarray:
397
  return values[np.isfinite(values)]
398
 
399
 
400
- def summarize_selector_cache(model_path: Path, dataset: str, exclude_prefixes: list[str] | None = None) -> list[SelectorCacheSummary]:
 
 
 
 
401
  if exclude_prefixes is None:
402
  exclude_prefixes = list(DEFAULT_SELECTOR_HIDE_PREFIXES)
403
- return _summarize_selector_cache_cached(str(model_path), dataset, tuple(exclude_prefixes))
404
 
405
 
406
  @lru_cache(maxsize=256)
407
  def _summarize_selector_cache_cached(
408
- model_path_str: str,
409
  dataset: str,
410
  exclude_prefixes: tuple[str, ...],
411
  ) -> tuple[SelectorCacheSummary, ...]:
412
 
413
- cache_files = discover_selector_cache_files(Path(model_path_str), dataset)
414
 
415
  summaries: list[SelectorCacheSummary] = []
416
-
417
  for selector_name in sorted(cache_files):
418
  if any(selector_name.startswith(p) for p in exclude_prefixes):
419
  continue
420
 
421
  cache_path = cache_files[selector_name]
422
-
423
  max_v, mean_v, var_v, min_v = summarize_selector_cache_file(cache_path)
424
 
425
  summaries.append(
 
426
  selector_name=selector_name,
427
- cache_path=str(cache_path),
428
  max_value=max_v,
429
  mean_value=mean_v,
430
  variance_value=var_v,
431
  min_value=min_v,
432
  )
 
433
  return tuple(summaries)
434
 
 
435
  def format_selector_cache_summary(summaries: list[SelectorCacheSummary]) -> list[str]:
436
  if not summaries:
437
  return ["No selector cache files were found for this model."]
@@ -451,54 +519,102 @@ def format_selector_cache_summary(summaries: list[SelectorCacheSummary]) -> list
451
  return lines
452
 
453
 
454
- def _discover_cache_dir_for_dataset(model_path: Path, dataset: str) -> Path | None:
455
- dataset_name = str(dataset).strip()
456
- if not dataset_name:
457
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
 
459
- # Prefer the most specific/most-recent cache directory
460
- candidates = sorted(model_path.glob(f"**/cache/{dataset_name}"), key=lambda p: str(p), reverse=True)
461
- return candidates[0] if candidates else None
462
-
463
-
464
- def _resolve_activation_cache_base_path(cache_dir: Path) -> Path | None:
465
- meta_paths = sorted(cache_dir.glob('*_meta.feather'), key=lambda p: p.name, reverse=True)
466
- for meta_path in meta_paths:
467
- base_name = meta_path.name.removesuffix('_meta.feather')
468
- codes_path = meta_path.with_name(f'{base_name}_codes.npz')
469
- if codes_path.exists():
470
- return meta_path.with_name(base_name)
471
- return None
472
-
473
- DEFAULT_FILTERS = [ {
474
- 'name': 'nonzero_max',
475
- 'params': {},
476
- },
477
- {
478
- 'name': 'kruskal_wallis',
479
- 'params': {
480
- 'alpha': 0.05,
481
- 'group_col': 'dist_type',
482
- 'min_group_size': 3,
483
- },
484
- }]
485
-
486
- def load_and_filter_model_activations(model_path: Path,
487
- dataset: str,
488
- requested_filters: list[dict] = DEFAULT_FILTERS,
489
- min_distortion_level = 3,
490
- max_distortion_level = 5) -> FilteredActivationCache:
491
- cache_dir = _discover_cache_dir_for_dataset(model_path, dataset)
492
- if cache_dir is None:
493
- raise FileNotFoundError(f'No activation cache directory found for dataset={dataset!r} in {model_path}')
494
-
495
- cache_dir = _discover_cache_dir_for_dataset(model_path, dataset)
496
- if cache_dir is None:
497
- raise FileNotFoundError(f'No activation cache directory found for dataset={dataset!r} in {model_path}')
498
-
499
- cache_base_path = _resolve_activation_cache_base_path(cache_dir)
500
- if cache_base_path is None:
501
- raise FileNotFoundError(f'No activation cache files found in {cache_dir}')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
 
503
  meta_df, codes_csr, activation_steps_csr = load_cache(
504
  str(cache_base_path),
@@ -506,8 +622,13 @@ def load_and_filter_model_activations(model_path: Path,
506
  min_distortion_level=min_distortion_level,
507
  max_distortion_level=max_distortion_level
508
  )
 
 
 
 
 
509
 
510
- feature_filter_cache_dir = cache_dir / '../feature_filter_cache'
511
  feature_filter_summary: list[dict[str, Any]] = []
512
 
513
  if not requested_filters:
@@ -578,7 +699,7 @@ def load_and_filter_model_activations(model_path: Path,
578
 
579
 
580
  @lru_cache(maxsize=256)
581
- def summarize_feature_filter_cache(model_path: Path, dataset: str) -> list[str]:
582
  """Return human-readable summary lines for feature filtering.
583
 
584
  The model cache is loaded first, then configured feature filters are applied
@@ -587,7 +708,7 @@ def summarize_feature_filter_cache(model_path: Path, dataset: str) -> list[str]:
587
  ``load_and_filter_model_activations()`` for downstream use.
588
  """
589
  try:
590
- filtered_cache = load_and_filter_model_activations(Path(model_path), dataset)
591
  except Exception as exc:
592
  return [f'Feature filtering unavailable: {exc}']
593
 
 
1
  from __future__ import annotations
2
 
3
  import ast
4
+ import json
5
  import os
6
  import re
7
  from dataclasses import dataclass
8
  from pathlib import Path
9
+ from typing import Any, Iterable, Mapping, Sequence
10
 
11
  import numpy as np
12
  from functools import lru_cache
13
  import pandas as pd
14
  import scipy.sparse as sp
15
 
16
+ from analysis.cache_utils import load_cache, zero_codes_outside_activation_steps
17
+ from analysis.config import (
18
+ SUPPORTED_DATASETS,
19
+ DatasetCachePaths,
20
+ build_dataset_cache_paths,
21
+ load_sae_vis_config,
22
+ )
23
  from analysis.features.feature_filters import (
24
  build_filter,
25
  )
 
28
 
29
  PROJECT_ROOT = Path(__file__).resolve().parents[1]
30
  DEFAULT_MODEL_STORAGE = PROJECT_ROOT / "logs"
31
+ MODEL_FAMILIES = ("ARNIQA", "MANIQA", "LIQE")
32
  METRIC_DESCRIPTIONS: dict[str, tuple[str, ...]] = {
33
  "MANIQA": (
34
  "No-reference IQA with two Swin Transformer stages: stage 1 (768-d) and stage 2 (384-d). "
 
48
  "l0 is the validation mean count of active SAE features per image (higher = less sparse).",
49
  ),
50
  }
 
 
51
  _FAMILY_ROOT_CANDIDATES = {
52
  "MANIQA": ("maniqa_logs", "MANIQA_logs", "maniqa"),
53
  "ARNIQA": ("arniqa_logs", "ARNIQA_logs", "arniqa"),
 
68
  class ModelRecord:
69
  family: str
70
  model_name: str
71
+ model_path: str
72
+ checkpoint_path: str
73
  experiment_num: int
74
  layer_num: int | None
75
  lambda_token: str | None
 
240
  )
241
 
242
 
243
+ def _latest_checkpoint_dir(model_root: Path) -> Path | None:
244
+ candidates: list[tuple[int, Path]] = []
245
+ for entry in model_root.iterdir():
246
+ if not entry.is_dir() or not entry.name.startswith('checkpoint-'):
247
+ continue
248
+ try:
249
+ step = int(entry.name.removeprefix('checkpoint-'))
250
+ except ValueError:
251
+ continue
252
+ candidates.append((step, entry))
253
+ if not candidates:
254
+ return None
255
+ candidates.sort(key=lambda item: item[0])
256
+ return candidates[-1][1]
257
+
258
+
259
  @lru_cache(maxsize=32)
260
  def _discover_models_for_metric_cached(metric_name: str, storage_root_str: str) -> tuple[ModelRecord, ...]:
261
  storage_root = Path(storage_root_str)
262
  family_root = resolve_family_root(metric_name, model_storage=storage_root)
263
+
264
  if not family_root.exists():
265
  return ()
266
 
 
282
  except Exception:
283
  l0_loss_value = None
284
 
285
+ checkpoint_dir = _latest_checkpoint_dir(model_dir)
286
+ if checkpoint_dir is None:
287
+ continue
288
+
289
  records.append(
290
  ModelRecord(
291
  family=metric_name,
292
  model_name=str(model_dir.name),
293
+ model_path=str(model_dir),
294
+ checkpoint_path=str(checkpoint_dir.resolve()),
295
  experiment_num=int(parsed_name["experiment_num"] or -1),
296
  layer_num=parsed_name["layer_num"],
297
  lambda_token=parsed_name["lambda_token"],
 
304
 
305
  records.sort(key=lambda r: (r.experiment_num, r.model_name), reverse=True)
306
 
307
+ return tuple(records)
308
+
309
+
310
+ def get_model_record(
311
+ metric: str,
312
+ model_key: str,
313
+ model_storage: Path | None = None,
314
+ ) -> ModelRecord | None:
315
+ return next(
316
+ (
317
+ record
318
+ for record in discover_models_for_metric(metric, model_storage)
319
+ if record.model_key == model_key
320
+ ),
321
+ None,
322
+ )
323
+
324
+
325
+ def require_model_record(
326
+ metric: str,
327
+ model_key: str,
328
+ model_storage: Path | None = None,
329
+ ) -> ModelRecord:
330
+ record = get_model_record(metric, model_key, model_storage)
331
+ if record is None:
332
+ raise FileNotFoundError(f'Model {model_key!r} not found for metric {metric!r}')
333
+ return record
334
 
335
 
336
  def build_model_option_label(record: ModelRecord) -> str:
 
351
  return "\n • ".join(params_split)
352
 
353
 
354
+ def checkpoint_cache_dir(checkpoint_path: str | Path) -> Path:
355
+ return Path(checkpoint_path) / 'cache'
356
+
357
+
358
+ def checkpoint_dataset_cache_paths(checkpoint_path: str | Path, dataset: str) -> DatasetCachePaths:
359
+ dataset_name = str(dataset).strip()
360
+ if dataset_name not in SUPPORTED_DATASETS:
361
+ raise ValueError(
362
+ f'Unsupported dataset={dataset_name!r}; expected one of {SUPPORTED_DATASETS}'
363
+ )
364
+ cache_dir = checkpoint_cache_dir(checkpoint_path)
365
+ return build_dataset_cache_paths(str(cache_dir), SUPPORTED_DATASETS)[dataset_name]
366
+
367
+
368
+ def selector_cache_files_for_model(checkpoint_path: Path | str, dataset: str) -> dict[str, Path]:
369
  dataset_name = str(dataset).strip()
370
  if not dataset_name:
371
  return {}
372
 
373
+ dataset_dir = checkpoint_cache_dir(checkpoint_path) / dataset_name
374
+ if not dataset_dir.is_dir():
375
+ return {}
376
+
377
+ patch_only = dataset_name.lower() in ('local_kadid', 'srground')
378
+ parquet_paths = sorted(dataset_dir.glob('*.parquet'))
379
+ patch_stems = {path.stem for path in parquet_paths if '_patch' in path.stem}
380
  cache_files: dict[str, Path] = {}
381
+ for parquet_path in parquet_paths:
382
+ stem = parquet_path.stem
383
+ if patch_only and '_patch' not in stem and f'{stem}_patch' in patch_stems:
384
+ continue
385
+ cache_files[stem] = parquet_path
 
 
 
 
 
 
 
 
386
  return cache_files
387
 
388
 
 
393
  aggregate those per-feature scores across all features. Returns
394
  (max, mean, variance, min) computed over per-feature scores.
395
  """
396
+ feature_table = load_selector_cache_feature_table(str(cache_path))
397
  if feature_table.empty:
398
  return None, None, None, None
399
 
 
405
  return float(arr.max()), float(arr.mean()), float(arr.var()), float(arr.min())
406
 
407
 
408
+ @lru_cache(maxsize=256)
409
+ def load_selector_cache_feature_table(cache_path_str: str) -> pd.DataFrame:
410
  """Return ordered per-feature selector scores with their feature ids.
411
 
412
  The returned frame contains:
 
414
  - feature_score: max absolute score for that feature column
415
  - feature_rank: 1-based rank after sorting by score descending, then feature id ascending
416
  """
 
 
 
 
 
 
 
417
  columns = ["feature_id", "feature_score", "feature_rank"]
418
+ path = Path(cache_path_str)
419
+ if not path.exists():
420
  return pd.DataFrame(columns=columns)
421
+
422
+ table = pd.read_parquet(path)
423
  numeric = table.select_dtypes(include=["number"])
424
  if numeric.empty:
425
  return pd.DataFrame(columns=columns)
 
451
 
452
  Each value is the max absolute value for one numeric feature column.
453
  """
454
+ feature_table = load_selector_cache_feature_table(str(cache_path))
455
  if feature_table.empty:
456
  return np.array([], dtype=float)
457
 
 
459
  return values[np.isfinite(values)]
460
 
461
 
462
+ def summarize_selector_cache(
463
+ checkpoint_path: Path | str,
464
+ dataset: str,
465
+ exclude_prefixes: list[str] | None = None,
466
+ ) -> list[SelectorCacheSummary]:
467
  if exclude_prefixes is None:
468
  exclude_prefixes = list(DEFAULT_SELECTOR_HIDE_PREFIXES)
469
+ return _summarize_selector_cache_cached(str(checkpoint_path), dataset, tuple(exclude_prefixes))
470
 
471
 
472
  @lru_cache(maxsize=256)
473
  def _summarize_selector_cache_cached(
474
+ checkpoint_path_str: str,
475
  dataset: str,
476
  exclude_prefixes: tuple[str, ...],
477
  ) -> tuple[SelectorCacheSummary, ...]:
478
 
479
+ cache_files = selector_cache_files_for_model(checkpoint_path_str, dataset)
480
 
481
  summaries: list[SelectorCacheSummary] = []
 
482
  for selector_name in sorted(cache_files):
483
  if any(selector_name.startswith(p) for p in exclude_prefixes):
484
  continue
485
 
486
  cache_path = cache_files[selector_name]
487
+
488
  max_v, mean_v, var_v, min_v = summarize_selector_cache_file(cache_path)
489
 
490
  summaries.append(
491
+ SelectorCacheSummary(
492
  selector_name=selector_name,
493
+ cache_path=str(cache_path), # <-- FIX
494
  max_value=max_v,
495
  mean_value=mean_v,
496
  variance_value=var_v,
497
  min_value=min_v,
498
  )
499
+ )
500
  return tuple(summaries)
501
 
502
+
503
  def format_selector_cache_summary(summaries: list[SelectorCacheSummary]) -> list[str]:
504
  if not summaries:
505
  return ["No selector cache files were found for this model."]
 
519
  return lines
520
 
521
 
522
+ def _acts_meta_path(acts_cache_path: Path) -> Path:
523
+ stem = acts_cache_path.stem.removesuffix('.feather')
524
+ return acts_cache_path.parent / f'{stem}_meta.feather'
525
+
526
+
527
+ @lru_cache(maxsize=1)
528
+ def _dashboard_default_params():
529
+ return load_sae_vis_config()
530
+
531
+
532
+ def _filters_to_cache_key(requested_filters: list[dict]) -> str:
533
+ return json.dumps(requested_filters, sort_keys=True, default=str)
534
+
535
+
536
+ def _activation_steps_cache_key(activation_steps_to_keep: Sequence[int]) -> str:
537
+ return json.dumps([int(step) for step in activation_steps_to_keep])
538
+
539
+
540
+ def clear_activation_load_cache() -> None:
541
+ """Drop in-process activation caches (e.g. after rebuilding cache files on disk)."""
542
+ _load_and_filter_model_activations_cached.cache_clear()
543
+
544
 
545
+ @lru_cache(maxsize=32)
546
+ def _load_and_filter_model_activations_cached(
547
+ checkpoint_path: str,
548
+ dataset: str,
549
+ filters_key: str,
550
+ min_distortion_level: int,
551
+ max_distortion_level: int,
552
+ activation_steps_key: str,
553
+ ) -> FilteredActivationCache:
554
+ requested_filters = json.loads(filters_key)
555
+ activation_steps_to_keep = [int(step) for step in json.loads(activation_steps_key)]
556
+ return _load_and_filter_model_activations_impl(
557
+ checkpoint_path,
558
+ dataset,
559
+ requested_filters=requested_filters,
560
+ min_distortion_level=min_distortion_level,
561
+ max_distortion_level=max_distortion_level,
562
+ activation_steps_to_keep=activation_steps_to_keep,
563
+ )
564
+
565
+
566
+ def load_and_filter_model_activations(
567
+ checkpoint_path: Path | str,
568
+ dataset: str,
569
+ *,
570
+ requested_filters: list[dict] | None = None,
571
+ min_distortion_level: int | None = None,
572
+ max_distortion_level: int | None = None,
573
+ activation_steps_to_keep: Sequence[int] | None = None,
574
+ ) -> FilteredActivationCache:
575
+ default_cfg = _dashboard_default_params()
576
+ filters = list(default_cfg.FEATURE_FILTERS if requested_filters is None else requested_filters)
577
+ min_level = int(
578
+ min_distortion_level
579
+ if min_distortion_level is not None
580
+ else default_cfg.KADID_MIN_DISTORTION_LEVEL
581
+ )
582
+ max_level = int(
583
+ max_distortion_level
584
+ if max_distortion_level is not None
585
+ else default_cfg.KADID_MAX_DISTORTION_LEVEL
586
+ )
587
+ keep_steps = list(
588
+ activation_steps_to_keep
589
+ if activation_steps_to_keep is not None
590
+ else default_cfg.ACTIVATION_STEPS_TO_KEEP
591
+ )
592
+ return _load_and_filter_model_activations_cached(
593
+ str(Path(checkpoint_path).resolve()),
594
+ str(dataset),
595
+ _filters_to_cache_key(filters),
596
+ min_level,
597
+ max_level,
598
+ _activation_steps_cache_key(keep_steps),
599
+ )
600
+
601
+
602
+ def _load_and_filter_model_activations_impl(
603
+ checkpoint_path: str,
604
+ dataset: str,
605
+ *,
606
+ requested_filters: list[dict],
607
+ min_distortion_level: int,
608
+ max_distortion_level: int,
609
+ activation_steps_to_keep: Sequence[int],
610
+ ) -> FilteredActivationCache:
611
+ cache_paths = checkpoint_dataset_cache_paths(checkpoint_path, dataset)
612
+ acts_cache_path = Path(cache_paths.acts_cache_path)
613
+ meta_path = _acts_meta_path(acts_cache_path)
614
+ if not meta_path.exists():
615
+ raise FileNotFoundError(f'Activation cache not found: {meta_path}')
616
+
617
+ cache_base_path = acts_cache_path
618
 
619
  meta_df, codes_csr, activation_steps_csr = load_cache(
620
  str(cache_base_path),
 
622
  min_distortion_level=min_distortion_level,
623
  max_distortion_level=max_distortion_level
624
  )
625
+ codes_csr = zero_codes_outside_activation_steps(
626
+ codes_csr,
627
+ activation_steps_csr,
628
+ activation_steps_to_keep,
629
+ )
630
 
631
+ feature_filter_cache_dir = Path(cache_paths.cache_dir) / 'feature_filter_cache'
632
  feature_filter_summary: list[dict[str, Any]] = []
633
 
634
  if not requested_filters:
 
699
 
700
 
701
  @lru_cache(maxsize=256)
702
+ def summarize_feature_filter_cache(checkpoint_path: Path | str, dataset: str) -> list[str]:
703
  """Return human-readable summary lines for feature filtering.
704
 
705
  The model cache is loaded first, then configured feature filters are applied
 
708
  ``load_and_filter_model_activations()`` for downstream use.
709
  """
710
  try:
711
+ filtered_cache = load_and_filter_model_activations(checkpoint_path, dataset)
712
  except Exception as exc:
713
  return [f'Feature filtering unavailable: {exc}']
714
 
dashboard/umap_service.py CHANGED
@@ -11,14 +11,29 @@ import pandas as pd
11
  import plotly.graph_objects as go
12
 
13
  from analysis.cache_utils import load_pristine_cache, zero_codes_outside_activation_steps
14
- from analysis.config import dataset_images_root, load_sae_vis_config
15
  from analysis.datasets import Kadid10kDataset
16
- from analysis.features.feature_matrix import build_image_feature_matrix, build_image_feature_matrix_raw
 
 
 
 
17
  from analysis.features.feature_indexing import FeatureMatrix, global_id_set, global_to_column
18
  from analysis.metrics.correlations import compute_distortion_correlations
19
  from analysis.viz.umap_plot import build_umap_figure, make_thumb_b64 as _make_thumb_b64
20
- from analysis.viz.umap_utils import get_or_compute_umap_with_builder
21
- from dashboard.model_catalog import discover_models_for_metric, load_and_filter_model_activations
 
 
 
 
 
 
 
 
 
 
 
22
  import scipy.sparse as sp
23
  from analysis.models import load_sae
24
 
@@ -42,7 +57,6 @@ CORR_GARBAGE_THRESHOLD = 0.20
42
 
43
  # Activation cache and image previews for dashboard UMAP (images + features modes).
44
  UMAP_DATASET = 'kadid10k'
45
-
46
  UMAP_PARAMS = dict(
47
  n_neighbors=15,
48
  min_dist=0.1,
@@ -62,6 +76,8 @@ class UmapContext:
62
  model_key: str
63
  features: FeatureMatrix
64
  image_row_indices: tuple[tuple[int, ...], ...]
 
 
65
  n_images_used: int
66
  n_features_total: int
67
  dist_type_arr: np.ndarray
@@ -69,32 +85,36 @@ class UmapContext:
69
  dist_level_arr: np.ndarray
70
  mos_arr: np.ndarray
71
  umap_cache_dir: Path
 
72
  kadid_ds: Any | None
73
  meta_df: pd.DataFrame
74
  use_reference_delta: bool
 
75
  pristine_meta_df: pd.DataFrame | None = None
76
  pristine_features: FeatureMatrix | None = None
77
  kadid_image_idx: np.ndarray | None = None
78
 
79
 
80
- def _resolve_checkpoint_path(model_path: str | Path) -> Path:
81
- model_root = Path(model_path)
82
- if model_root.is_file():
83
- return model_root
84
-
85
- candidate_dirs: list[Path] = []
86
- for pattern in ('checkpoint-*', 'checkpoints/checkpoint-*', 'checkpoint'):
87
- candidate_dirs.extend(p for p in model_root.glob(pattern) if p.is_dir())
88
-
89
- if not candidate_dirs:
90
- return model_root
91
-
92
- def _checkpoint_sort_key(path: Path) -> tuple[int, str]:
93
- digits = ''.join(ch for ch in path.name if ch.isdigit())
94
- numeric = int(digits) if digits else -1
95
- return numeric, str(path)
96
-
97
- return max(candidate_dirs, key=_checkpoint_sort_key)
 
 
98
 
99
 
100
  def empty_umap_figure(message: str) -> go.Figure:
@@ -137,39 +157,6 @@ def _compact_meta_image_indices(meta_df: pd.DataFrame) -> tuple[pd.DataFrame, in
137
  return meta_out, int(unique_ids.size)
138
 
139
 
140
- def _kadid_index_lookup(meta_df: pd.DataFrame, kadid_ds: Any, n_images_used: int) -> np.ndarray:
141
- """Map dense UMAP image index (0..n-1) to ``Kadid10kDataset`` positional index."""
142
- lookup = np.arange(n_images_used, dtype=np.int32)
143
- if kadid_ds is None:
144
- return lookup
145
-
146
- first_rows = meta_df.groupby('image_idx', sort=True).first()
147
- kadid_paths = [str(p) for p in kadid_ds.images]
148
- path_to_idx = {p: i for i, p in enumerate(kadid_paths)}
149
- base_to_idx = {os.path.basename(p): i for i, p in enumerate(kadid_paths)}
150
-
151
- for dense_i in range(n_images_used):
152
- row = first_rows.iloc[dense_i]
153
- kadid_idx = None
154
- for col in ('distorted_img_path', 'image_path', 'original_img_path'):
155
- if col not in row.index:
156
- continue
157
- val = row[col]
158
- if val is None or (isinstance(val, float) and np.isnan(val)):
159
- continue
160
- path = str(val)
161
- kadid_idx = path_to_idx.get(path)
162
- if kadid_idx is None and not Path(path).is_absolute():
163
- kadid_idx = path_to_idx.get(str(kadid_ds.root / Path(path)))
164
- if kadid_idx is None:
165
- kadid_idx = base_to_idx.get(os.path.basename(path))
166
- if kadid_idx is not None:
167
- break
168
- if kadid_idx is not None:
169
- lookup[dense_i] = int(kadid_idx)
170
- return lookup
171
-
172
-
173
  def _image_labels_from_meta(meta_df, n_images_used: int) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
174
  first_rows = meta_df.groupby('image_idx', sort=True).first()
175
  expected_index = np.arange(n_images_used, dtype=np.int32)
@@ -271,34 +258,6 @@ def _build_reference_delta_matrix(
271
  )
272
 
273
 
274
- def _resolve_kadid_dataset(
275
- dataset: str,
276
- n_images_used: int,
277
- kadid_images_path: Optional[str],
278
- crop_size: Optional[int],
279
- min_distortion_level: Optional[int],
280
- ):
281
- if 'kadid' not in str(dataset).lower():
282
- return None
283
- try:
284
- if kadid_images_path is None or crop_size is None or min_distortion_level is None:
285
- cfg = load_sae_vis_config()
286
- kadid_images_path = kadid_images_path or cfg.KADID_IMAGES_PATH
287
- crop_size = crop_size or cfg.CROP_SIZE
288
- min_distortion_level = min_distortion_level or cfg.KADID_MIN_DISTORTION_LEVEL
289
-
290
- kadid_ds = Kadid10kDataset(
291
- kadid_images_path,
292
- crop_size=crop_size,
293
- min_distortion_level=min_distortion_level,
294
- )
295
- if n_images_used > len(kadid_ds):
296
- return None
297
- return kadid_ds
298
- except Exception:
299
- return None
300
-
301
-
302
  @lru_cache(maxsize=32)
303
  def load_umap_context(
304
  metric: str,
@@ -312,54 +271,48 @@ def load_umap_context(
312
  crop_size: Optional[int] = None,
313
  use_reference_delta: bool = True,
314
  ) -> UmapContext:
315
- record = next((r for r in discover_models_for_metric(metric) if r.model_key == model_key), None)
316
- if record is None:
317
- raise FileNotFoundError(f'Model {model_key!r} not found for metric {metric!r}')
318
-
319
- # Load default config only for missing parameters
320
- cfg: Optional[object] = None
321
- need_cfg = any(
322
- v is None
323
- for v in (
324
- feature_filters,
325
- kadid_min_dist_level,
326
- kadid_max_dist_level,
327
- activation_steps_to_keep,
328
- kadid_images_path,
329
- crop_size,
330
- )
331
- )
332
- if need_cfg:
333
- cfg = load_sae_vis_config()
334
 
335
- requested_filters = list(feature_filters if feature_filters is not None else cfg.FEATURE_FILTERS)
336
- min_level = kadid_min_dist_level if kadid_min_dist_level is not None else cfg.KADID_MIN_DISTORTION_LEVEL
337
- max_level = kadid_max_dist_level if kadid_max_dist_level is not None else cfg.KADID_MAX_DISTORTION_LEVEL
 
 
 
338
 
339
  filtered = load_and_filter_model_activations(
340
- Path(record.model_path),
341
  dataset,
342
- requested_filters=requested_filters,
343
- min_distortion_level=min_level,
344
- max_distortion_level=max_level,
 
345
  )
346
 
347
- keep_steps = activation_steps_to_keep if activation_steps_to_keep is not None else (cfg.ACTIVATION_STEPS_TO_KEEP if cfg is not None else [])
348
- codes_csr = zero_codes_outside_activation_steps(
349
- filtered.features.codes,
350
- filtered.activation_steps_csr,
351
- keep_steps,
352
- )
353
- features = FeatureMatrix(
354
- codes=codes_csr,
355
- column_feature_ids=filtered.features.column_feature_ids,
356
- )
357
 
358
  meta_df = filtered.meta_df
359
  if 'image_idx' not in meta_df.columns:
360
  raise ValueError('Activation cache metadata is missing required column "image_idx"')
361
 
362
- meta_df, n_images_used = _compact_meta_image_indices(meta_df)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  image_idx_arr = meta_df['image_idx'].to_numpy(dtype=np.int32)
364
  n_features_total = features.n_features
365
 
@@ -370,12 +323,12 @@ def load_umap_context(
370
  image_row_indices = _build_image_row_indices(image_idx_arr, n_images_used)
371
 
372
  dist_type_arr, dist_group_arr, dist_level_arr, mos_arr = _image_labels_from_meta(meta_df, n_images_used)
373
- cache_dir = Path(record.model_path)
374
- discovered = sorted(cache_dir.glob(f'**/cache/{dataset}'), key=lambda p: str(p), reverse=True)
375
- umap_cache_dir = (discovered[0] / 'umap_cache') if discovered else (cache_dir / 'cache' / dataset / 'umap_cache')
376
 
377
  pristine_meta_df: pd.DataFrame | None = None
378
  pristine_features: FeatureMatrix | None = None
 
379
  if use_reference_delta:
380
  try:
381
  pristine_meta_df, pristine_codes, pristine_steps = load_pristine_cache(str(filtered.cache_base_path), return_activation_steps=True)
@@ -391,9 +344,14 @@ def load_umap_context(
391
  raise ValueError(
392
  f'Pristine and distorted feature dimensions differ: {pristine_features.n_features} != {n_features_total}'
393
  )
 
 
394
  except Exception as exc:
395
  print(f'[umap] reference delta unavailable, falling back to raw features: {exc}')
396
  use_reference_delta = False
 
 
 
397
 
398
  kadid_ds = _kadid_dataset_for_context_cached(
399
  metric,
@@ -401,7 +359,7 @@ def load_umap_context(
401
  dataset,
402
  n_images_used,
403
  )
404
- kadid_image_idx = _kadid_index_lookup(meta_df, kadid_ds, n_images_used)
405
 
406
  return UmapContext(
407
  metric=metric,
@@ -409,6 +367,8 @@ def load_umap_context(
409
  model_key=model_key,
410
  features=features,
411
  image_row_indices=image_row_indices,
 
 
412
  n_images_used=n_images_used,
413
  n_features_total=n_features_total,
414
  dist_type_arr=dist_type_arr,
@@ -416,9 +376,11 @@ def load_umap_context(
416
  dist_level_arr=dist_level_arr,
417
  mos_arr=mos_arr,
418
  umap_cache_dir=umap_cache_dir,
 
419
  kadid_ds=kadid_ds,
420
  meta_df=meta_df,
421
  use_reference_delta=use_reference_delta,
 
422
  pristine_meta_df=pristine_meta_df,
423
  pristine_features=pristine_features,
424
  kadid_image_idx=kadid_image_idx,
@@ -434,15 +396,18 @@ def _kadid_dataset_for_context_cached(
434
  ) -> Any:
435
  if 'kadid' not in str(dataset).lower():
436
  return None
 
 
 
437
  try:
438
- cfg = load_sae_vis_config()
439
- images_root = dataset_images_root(cfg.DATASETS_ROOT, dataset)
440
  return _resolve_kadid_dataset(
441
  dataset,
442
  n_images_used,
443
  images_root,
444
- cfg.CROP_SIZE,
445
- cfg.KADID_MIN_DISTORTION_LEVEL,
446
  )
447
  except Exception:
448
  return None
@@ -464,11 +429,13 @@ def _labels_for_color_mode(context: UmapContext, color_mode: str) -> np.ndarray:
464
 
465
 
466
  @lru_cache(maxsize=16)
467
- def _resolve_feature_atoms_cached(metric: str, model_key: str) -> tuple[np.ndarray, tuple[int, ...]]:
468
- record = next((r for r in discover_models_for_metric(metric) if r.model_key == model_key), None)
469
- if record is None:
470
- raise FileNotFoundError(f'Model {model_key!r} not found for metric {metric!r}')
471
- checkpoint_path = _resolve_checkpoint_path(record.model_path)
 
 
472
  sae = load_sae(checkpoint_path=str(checkpoint_path), device='cpu')
473
  try:
474
  decoder_weight = sae.decoder.weight
@@ -513,13 +480,17 @@ def _feature_stats_for_ids(
513
  return means, stds, nonzeros
514
 
515
 
 
 
 
 
 
 
 
 
 
516
  def _feature_stats(context: UmapContext) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
517
- return _feature_stats_cached(
518
- context.metric,
519
- context.dataset,
520
- context.model_key,
521
- bool(context.use_reference_delta),
522
- )
523
 
524
 
525
  @lru_cache(maxsize=32)
@@ -527,9 +498,8 @@ def _feature_stats_cached(
527
  metric: str,
528
  dataset: str,
529
  model_key: str,
530
- use_reference_delta: bool,
531
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
532
- context = load_umap_context(metric, dataset, model_key, use_reference_delta=use_reference_delta)
533
  return _feature_stats_all_columns(context)
534
 
535
 
@@ -544,12 +514,10 @@ def _feature_marker_sizes(mean_feats: np.ndarray) -> np.ndarray:
544
 
545
 
546
  def _feature_similarity_cache_path(context: UmapContext, source: str = 'type') -> str:
547
- cfg = load_sae_vis_config()
548
- dataset_cfg = cfg.DATASET_CACHE_CONFIGS[context.dataset]
549
  if source == 'type':
550
- return dataset_cfg.corr_type_cache_path
551
  if source == 'group':
552
- return dataset_cfg.corr_group_cache_path
553
  raise ValueError(f"source must be 'type' or 'group', got {source!r}")
554
 
555
 
@@ -646,24 +614,28 @@ def _feature_top_corr_labels(
646
  return np.asarray(top_labels, dtype=object)
647
 
648
 
649
- def _cache_key(context: UmapContext, aggregation_mode: str) -> Tuple:
650
- return (
651
- context.metric,
652
- context.dataset,
653
- context.model_key,
654
- str(aggregation_mode),
655
- bool(context.use_reference_delta),
656
- tuple(int(fid) for fid in context.features.column_feature_ids),
657
- tuple(sorted(UMAP_PARAMS.items())),
658
- )
659
 
660
 
661
  def _umap_cache_signature(context: UmapContext) -> dict[str, object]:
662
- signature = {
663
- 'codes_shape': tuple(int(v) for v in context.features.codes.shape),
 
664
  'n_images_used': int(context.n_images_used),
665
  'n_features_total': int(context.n_features_total),
666
  'use_reference_delta': bool(context.use_reference_delta),
 
 
 
667
  'feature_ids': [int(fid) for fid in context.features.column_feature_ids],
668
  }
669
  if context.pristine_features is not None:
@@ -671,32 +643,21 @@ def _umap_cache_signature(context: UmapContext) -> dict[str, object]:
671
  return signature
672
 
673
 
674
- def get_or_compute_umap(context: UmapContext, aggregation_mode: str) -> dict[str, object]:
675
- key = _cache_key(context, aggregation_mode)
676
- cache_id = f'{context.model_key}_{context.dataset}_agg_{aggregation_mode}'
677
-
678
- def _build_features() -> np.ndarray:
679
- if context.use_reference_delta and context.pristine_features is not None and context.pristine_meta_df is not None:
680
- return _build_reference_delta_matrix(
681
- context.features,
682
- context.image_row_indices,
683
- context.meta_df,
684
- context.pristine_features,
685
- context.pristine_meta_df,
686
- context.n_images_used,
687
- aggregation_mode,
688
- )
689
 
690
- return build_image_feature_matrix(
691
- context.features,
692
- context.image_row_indices,
693
- context.n_images_used,
694
- aggregation_mode,
695
- )
696
 
697
  return get_or_compute_umap_with_builder(
698
- key,
699
- _build_features,
700
  UMAP_PARAMS,
701
  cache_dir=context.umap_cache_dir,
702
  cache_id=cache_id,
@@ -704,35 +665,26 @@ def get_or_compute_umap(context: UmapContext, aggregation_mode: str) -> dict[str
704
  )
705
 
706
 
707
- def get_or_compute_feature_umap(context: UmapContext) -> dict[str, object]:
708
- key = (
709
- context.metric,
710
- context.dataset,
711
- context.model_key,
712
- 'features',
713
- tuple(sorted(UMAP_PARAMS.items())),
714
- )
715
- cache_id = f"{context.model_key}_{context.dataset}_features_v3"
716
 
717
  def _build_atoms() -> np.ndarray:
718
- atoms, _aligned = _resolve_feature_atoms_cached(context.metric, context.model_key)
719
  return atoms
720
 
721
- def _build_signature() -> dict[str, object]:
722
- sig = _umap_cache_signature(context)
723
- _atoms, aligned = _resolve_feature_atoms_cached(context.metric, context.model_key)
724
- sig['n_feature_atoms'] = len(aligned)
725
- return sig
726
-
727
  info = get_or_compute_umap_with_builder(
728
- key,
729
  _build_atoms,
730
  UMAP_PARAMS,
731
- cache_dir=context.umap_cache_dir,
732
  cache_id=cache_id,
733
- cache_signature=_build_signature(),
734
  )
735
- _atoms, aligned = _resolve_feature_atoms_cached(context.metric, context.model_key)
736
  expected_n = len(aligned)
737
  emb = np.asarray(info['embedding_2d'])
738
  if emb.shape[0] != expected_n:
@@ -760,10 +712,10 @@ def build_feature_umap_figure(
760
  of the code (including `context`, `feature_ids`, `unique_categories`, and
761
  cache-related fields returned by the UMAP computation).
762
  """
763
- context = load_umap_context(metric, dataset, model_key)
764
- _atoms, aligned_feature_ids = _resolve_feature_atoms_cached(context.metric, context.model_key)
765
  aligned_feature_ids = list(aligned_feature_ids)
766
- info = get_or_compute_feature_umap(context)
767
  emb = np.asarray(info['embedding_2d'])
768
  n_points = min(int(emb.shape[0]), len(aligned_feature_ids))
769
  emb = emb[:n_points]
@@ -872,7 +824,7 @@ def build_feature_umap_figure(
872
 
873
  @lru_cache(maxsize=32)
874
  def activation_feature_id_set(metric: str, dataset: str, model_key: str) -> frozenset[int]:
875
- context = load_umap_context(metric, dataset, model_key)
876
  return global_id_set(context.features.column_feature_ids)
877
 
878
 
@@ -973,10 +925,8 @@ def make_hover_thumb_b64(context: UmapContext, img_idx: int) -> str | None:
973
 
974
 
975
  def format_feature_umap_status(info: dict[str, object], color_mode: str) -> str:
976
- context = info.get('context')
977
- feature_mode = 'delta(ref)' if getattr(context, 'use_reference_delta', False) else 'raw'
978
  corr_source = str(info.get('feature_corr_source', DEFAULT_FEATURE_CORR_SOURCE))
979
- return f'mode={feature_mode} | color=corr({corr_source}) | X={info.get("x_shape")}'
980
 
981
 
982
  def format_image_color_mode(color_mode: str) -> str:
 
11
  import plotly.graph_objects as go
12
 
13
  from analysis.cache_utils import load_pristine_cache, zero_codes_outside_activation_steps
14
+ from analysis.config import dataset_images_root
15
  from analysis.datasets import Kadid10kDataset
16
+ from analysis.features.feature_matrix import (
17
+ build_image_feature_matrix,
18
+ build_image_feature_matrix_from_image_idx,
19
+ build_image_feature_matrix_raw,
20
+ )
21
  from analysis.features.feature_indexing import FeatureMatrix, global_id_set, global_to_column
22
  from analysis.metrics.correlations import compute_distortion_correlations
23
  from analysis.viz.umap_plot import build_umap_figure, make_thumb_b64 as _make_thumb_b64
24
+ from analysis.viz.umap_utils import (
25
+ UMAP_IMAGE_LIMIT,
26
+ UMAP_IMAGE_RANDOM_STATE,
27
+ build_kadid_index_lookup,
28
+ get_or_compute_umap_with_builder,
29
+ select_random_image_indices,
30
+ )
31
+ from dashboard.model_catalog import (
32
+ _dashboard_default_params,
33
+ get_model_record,
34
+ load_and_filter_model_activations,
35
+ require_model_record,
36
+ )
37
  import scipy.sparse as sp
38
  from analysis.models import load_sae
39
 
 
57
 
58
  # Activation cache and image previews for dashboard UMAP (images + features modes).
59
  UMAP_DATASET = 'kadid10k'
 
60
  UMAP_PARAMS = dict(
61
  n_neighbors=15,
62
  min_dist=0.1,
 
76
  model_key: str
77
  features: FeatureMatrix
78
  image_row_indices: tuple[tuple[int, ...], ...]
79
+ image_idx_arr: np.ndarray
80
+ selected_image_idx: tuple[int, ...]
81
  n_images_used: int
82
  n_features_total: int
83
  dist_type_arr: np.ndarray
 
85
  dist_level_arr: np.ndarray
86
  mos_arr: np.ndarray
87
  umap_cache_dir: Path
88
+ dataset_cache_dir: Path
89
  kadid_ds: Any | None
90
  meta_df: pd.DataFrame
91
  use_reference_delta: bool
92
+ delta_codes: Any | None = None
93
  pristine_meta_df: pd.DataFrame | None = None
94
  pristine_features: FeatureMatrix | None = None
95
  kadid_image_idx: np.ndarray | None = None
96
 
97
 
98
+ def _resolve_kadid_dataset(
99
+ dataset: str,
100
+ n_images_used: int,
101
+ kadid_images_path: str,
102
+ crop_size: int,
103
+ min_distortion_level: int,
104
+ ):
105
+ if 'kadid' not in str(dataset).lower():
106
+ return None
107
+ try:
108
+ kadid_ds = Kadid10kDataset(
109
+ kadid_images_path,
110
+ crop_size=crop_size,
111
+ min_distortion_level=min_distortion_level,
112
+ )
113
+ if n_images_used > len(kadid_ds):
114
+ return None
115
+ return kadid_ds
116
+ except Exception:
117
+ return None
118
 
119
 
120
  def empty_umap_figure(message: str) -> go.Figure:
 
157
  return meta_out, int(unique_ids.size)
158
 
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  def _image_labels_from_meta(meta_df, n_images_used: int) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
161
  first_rows = meta_df.groupby('image_idx', sort=True).first()
162
  expected_index = np.arange(n_images_used, dtype=np.int32)
 
258
  )
259
 
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  @lru_cache(maxsize=32)
262
  def load_umap_context(
263
  metric: str,
 
271
  crop_size: Optional[int] = None,
272
  use_reference_delta: bool = True,
273
  ) -> UmapContext:
274
+ record = require_model_record(metric, model_key)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
 
276
+ default_cfg = _dashboard_default_params()
277
+ keep_steps = list(
278
+ activation_steps_to_keep
279
+ if activation_steps_to_keep is not None
280
+ else default_cfg.ACTIVATION_STEPS_TO_KEEP
281
+ )
282
 
283
  filtered = load_and_filter_model_activations(
284
+ record.checkpoint_path,
285
  dataset,
286
+ requested_filters=feature_filters,
287
+ min_distortion_level=kadid_min_dist_level,
288
+ max_distortion_level=kadid_max_dist_level,
289
+ activation_steps_to_keep=keep_steps,
290
  )
291
 
292
+ features = filtered.features
293
+ codes_csr = features.codes
 
 
 
 
 
 
 
 
294
 
295
  meta_df = filtered.meta_df
296
  if 'image_idx' not in meta_df.columns:
297
  raise ValueError('Activation cache metadata is missing required column "image_idx"')
298
 
299
+ image_idx_arr = meta_df['image_idx'].to_numpy(dtype=np.int64)
300
+ max_images = int(image_idx_arr.max()) + 1
301
+ selected_image_idx = select_random_image_indices(
302
+ max_images,
303
+ limit=UMAP_IMAGE_LIMIT,
304
+ random_state=UMAP_IMAGE_RANDOM_STATE,
305
+ )
306
+ row_mask = np.isin(image_idx_arr, selected_image_idx)
307
+ dense_map = {int(orig): i for i, orig in enumerate(selected_image_idx.tolist())}
308
+ meta_df = meta_df.loc[row_mask].copy()
309
+ meta_df['image_idx'] = meta_df['image_idx'].map(lambda v: dense_map[int(v)]).astype(np.int32)
310
+ codes_csr = codes_csr[row_mask]
311
+ features = FeatureMatrix(
312
+ codes=codes_csr,
313
+ column_feature_ids=features.column_feature_ids,
314
+ )
315
+ n_images_used = int(selected_image_idx.size)
316
  image_idx_arr = meta_df['image_idx'].to_numpy(dtype=np.int32)
317
  n_features_total = features.n_features
318
 
 
323
  image_row_indices = _build_image_row_indices(image_idx_arr, n_images_used)
324
 
325
  dist_type_arr, dist_group_arr, dist_level_arr, mos_arr = _image_labels_from_meta(meta_df, n_images_used)
326
+ dataset_cache_dir = Path(record.checkpoint_path) / 'cache' / dataset
327
+ umap_cache_dir = Path(record.checkpoint_path) / 'cache' / 'umap_cache'
 
328
 
329
  pristine_meta_df: pd.DataFrame | None = None
330
  pristine_features: FeatureMatrix | None = None
331
+ delta_codes: Any | None = None
332
  if use_reference_delta:
333
  try:
334
  pristine_meta_df, pristine_codes, pristine_steps = load_pristine_cache(str(filtered.cache_base_path), return_activation_steps=True)
 
344
  raise ValueError(
345
  f'Pristine and distorted feature dimensions differ: {pristine_features.n_features} != {n_features_total}'
346
  )
347
+ pristine_row_indices = _build_reference_row_indices(meta_df, pristine_meta_df)
348
+ delta_codes = features.codes - pristine_features.codes[pristine_row_indices]
349
  except Exception as exc:
350
  print(f'[umap] reference delta unavailable, falling back to raw features: {exc}')
351
  use_reference_delta = False
352
+ pristine_meta_df = None
353
+ pristine_features = None
354
+ delta_codes = None
355
 
356
  kadid_ds = _kadid_dataset_for_context_cached(
357
  metric,
 
359
  dataset,
360
  n_images_used,
361
  )
362
+ kadid_image_idx = build_kadid_index_lookup(meta_df, kadid_ds, n_images_used)
363
 
364
  return UmapContext(
365
  metric=metric,
 
367
  model_key=model_key,
368
  features=features,
369
  image_row_indices=image_row_indices,
370
+ image_idx_arr=image_idx_arr,
371
+ selected_image_idx=tuple(int(v) for v in selected_image_idx.tolist()),
372
  n_images_used=n_images_used,
373
  n_features_total=n_features_total,
374
  dist_type_arr=dist_type_arr,
 
376
  dist_level_arr=dist_level_arr,
377
  mos_arr=mos_arr,
378
  umap_cache_dir=umap_cache_dir,
379
+ dataset_cache_dir=dataset_cache_dir,
380
  kadid_ds=kadid_ds,
381
  meta_df=meta_df,
382
  use_reference_delta=use_reference_delta,
383
+ delta_codes=delta_codes,
384
  pristine_meta_df=pristine_meta_df,
385
  pristine_features=pristine_features,
386
  kadid_image_idx=kadid_image_idx,
 
396
  ) -> Any:
397
  if 'kadid' not in str(dataset).lower():
398
  return None
399
+ record = get_model_record(metric, model_key)
400
+ if record is None:
401
+ return None
402
  try:
403
+ default_cfg = _dashboard_default_params()
404
+ images_root = dataset_images_root(default_cfg.DATASETS_ROOT, dataset)
405
  return _resolve_kadid_dataset(
406
  dataset,
407
  n_images_used,
408
  images_root,
409
+ default_cfg.CROP_SIZE,
410
+ default_cfg.KADID_MIN_DISTORTION_LEVEL,
411
  )
412
  except Exception:
413
  return None
 
429
 
430
 
431
  @lru_cache(maxsize=16)
432
+ def _resolve_feature_atoms_cached(
433
+ metric: str,
434
+ model_key: str,
435
+ dataset: str,
436
+ ) -> tuple[np.ndarray, tuple[int, ...]]:
437
+ record = require_model_record(metric, model_key)
438
+ checkpoint_path = Path(record.checkpoint_path)
439
  sae = load_sae(checkpoint_path=str(checkpoint_path), device='cpu')
440
  try:
441
  decoder_weight = sae.decoder.weight
 
480
  return means, stds, nonzeros
481
 
482
 
483
+ def _feature_umap_cache_signature(metric: str, model_key: str, dataset: str) -> dict[str, object]:
484
+ """Cache fingerprint for decoder-atom UMAP (matches notebook 07)."""
485
+ atoms, _aligned = _resolve_feature_atoms_cached(metric, model_key, dataset)
486
+ return {
487
+ 'n_feature_atoms': int(atoms.shape[0]),
488
+ 'atom_dim': int(atoms.shape[1]),
489
+ }
490
+
491
+
492
  def _feature_stats(context: UmapContext) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
493
+ return _feature_stats_cached(context.metric, context.dataset, context.model_key)
 
 
 
 
 
494
 
495
 
496
  @lru_cache(maxsize=32)
 
498
  metric: str,
499
  dataset: str,
500
  model_key: str,
 
501
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
502
+ context = load_umap_context(metric, dataset, model_key, use_reference_delta=False)
503
  return _feature_stats_all_columns(context)
504
 
505
 
 
514
 
515
 
516
  def _feature_similarity_cache_path(context: UmapContext, source: str = 'type') -> str:
 
 
517
  if source == 'type':
518
+ return str(context.dataset_cache_dir / 'corr_type.parquet')
519
  if source == 'group':
520
+ return str(context.dataset_cache_dir / 'corr_group.parquet')
521
  raise ValueError(f"source must be 'type' or 'group', got {source!r}")
522
 
523
 
 
614
  return np.asarray(top_labels, dtype=object)
615
 
616
 
617
+ def _umap_codes_for_signature(context: UmapContext):
618
+ if context.use_reference_delta and context.delta_codes is not None:
619
+ return context.delta_codes
620
+ return context.features.codes
621
+
622
+
623
+ def _images_umap_cache_id(context: UmapContext, aggregation_mode: str) -> str:
624
+ if context.use_reference_delta and context.delta_codes is not None:
625
+ return f'grid_delta_agg_{aggregation_mode}'
626
+ return f'grid_raw_agg_{aggregation_mode}'
627
 
628
 
629
  def _umap_cache_signature(context: UmapContext) -> dict[str, object]:
630
+ codes_for_sig = _umap_codes_for_signature(context)
631
+ signature: dict[str, object] = {
632
+ 'codes_shape': tuple(int(v) for v in codes_for_sig.shape),
633
  'n_images_used': int(context.n_images_used),
634
  'n_features_total': int(context.n_features_total),
635
  'use_reference_delta': bool(context.use_reference_delta),
636
+ 'image_selection': 'random',
637
+ 'random_state': int(UMAP_IMAGE_RANDOM_STATE),
638
+ 'selected_image_idx': [int(v) for v in context.selected_image_idx],
639
  'feature_ids': [int(fid) for fid in context.features.column_feature_ids],
640
  }
641
  if context.pristine_features is not None:
 
643
  return signature
644
 
645
 
646
+ def _build_images_umap_matrix(context: UmapContext, aggregation_mode: str) -> np.ndarray:
647
+ codes = _umap_codes_for_signature(context)
648
+ return build_image_feature_matrix_from_image_idx(
649
+ codes,
650
+ context.image_idx_arr,
651
+ context.n_images_used,
652
+ aggregation_mode,
653
+ )
 
 
 
 
 
 
 
654
 
655
+
656
+ def get_or_compute_umap(context: UmapContext, aggregation_mode: str) -> dict[str, object]:
657
+ cache_id = _images_umap_cache_id(context, aggregation_mode)
 
 
 
658
 
659
  return get_or_compute_umap_with_builder(
660
+ lambda: _build_images_umap_matrix(context, aggregation_mode),
 
661
  UMAP_PARAMS,
662
  cache_dir=context.umap_cache_dir,
663
  cache_id=cache_id,
 
665
  )
666
 
667
 
668
+ def get_or_compute_feature_umap(
669
+ metric: str,
670
+ model_key: str,
671
+ dataset: str,
672
+ umap_cache_dir: Path,
673
+ ) -> dict[str, object]:
674
+ cache_id = 'features_grid'
 
 
675
 
676
  def _build_atoms() -> np.ndarray:
677
+ atoms, _aligned = _resolve_feature_atoms_cached(metric, model_key, dataset)
678
  return atoms
679
 
 
 
 
 
 
 
680
  info = get_or_compute_umap_with_builder(
 
681
  _build_atoms,
682
  UMAP_PARAMS,
683
+ cache_dir=umap_cache_dir,
684
  cache_id=cache_id,
685
+ cache_signature=_feature_umap_cache_signature(metric, model_key, dataset),
686
  )
687
+ _atoms, aligned = _resolve_feature_atoms_cached(metric, model_key, dataset)
688
  expected_n = len(aligned)
689
  emb = np.asarray(info['embedding_2d'])
690
  if emb.shape[0] != expected_n:
 
712
  of the code (including `context`, `feature_ids`, `unique_categories`, and
713
  cache-related fields returned by the UMAP computation).
714
  """
715
+ context = load_umap_context(metric, dataset, model_key, use_reference_delta=False)
716
+ _atoms, aligned_feature_ids = _resolve_feature_atoms_cached(context.metric, context.model_key, dataset)
717
  aligned_feature_ids = list(aligned_feature_ids)
718
+ info = get_or_compute_feature_umap(context.metric, context.model_key, dataset, context.umap_cache_dir)
719
  emb = np.asarray(info['embedding_2d'])
720
  n_points = min(int(emb.shape[0]), len(aligned_feature_ids))
721
  emb = emb[:n_points]
 
824
 
825
  @lru_cache(maxsize=32)
826
  def activation_feature_id_set(metric: str, dataset: str, model_key: str) -> frozenset[int]:
827
+ context = load_umap_context(metric, dataset, model_key, use_reference_delta=False)
828
  return global_id_set(context.features.column_feature_ids)
829
 
830
 
 
925
 
926
 
927
  def format_feature_umap_status(info: dict[str, object], color_mode: str) -> str:
 
 
928
  corr_source = str(info.get('feature_corr_source', DEFAULT_FEATURE_CORR_SOURCE))
929
+ return f'features=decoder_atoms | color=corr({corr_source}) | X={info.get("x_shape")}'
930
 
931
 
932
  def format_image_color_mode(color_mode: str) -> str:
dashboard/user_image_service.py CHANGED
@@ -19,7 +19,7 @@ from analysis.models import load_iqa_model, load_sae, read_sae_config
19
  import analysis.models as _analysis_models
20
  from analysis.viz.vis_heatmaps import visualize_feature_heatmaps, visualize_feature_heatmaps_from_images
21
  from dashboard.image_utils import png_bytes_to_data_url
22
- from dashboard.model_catalog import discover_models_for_metric
23
 
24
 
25
  PROJECT_ROOT = Path(__file__).resolve().parents[1]
@@ -38,24 +38,6 @@ class FeatureMapRequest:
38
  upload_filename: str | None = None
39
 
40
 
41
- def _resolve_checkpoint_path(model_path: str | Path) -> Path:
42
- model_root = Path(model_path)
43
- if model_root.is_file():
44
- return model_root
45
-
46
- candidate_dirs: list[Path] = []
47
- for pattern in ("checkpoint-*", "checkpoints/checkpoint-*", "checkpoint"):
48
- candidate_dirs.extend(p for p in model_root.glob(pattern) if p.is_dir())
49
- if not candidate_dirs:
50
- return model_root
51
-
52
- def _checkpoint_sort_key(path: Path) -> tuple[int, str]:
53
- digits = "".join(ch for ch in path.name if ch.isdigit())
54
- numeric = int(digits) if digits else -1
55
- return numeric, str(path)
56
-
57
- return max(candidate_dirs, key=_checkpoint_sort_key)
58
-
59
 
60
  def _assets_url(path: Path) -> str:
61
  rel = path.relative_to(ASSETS_DIR)
@@ -213,12 +195,9 @@ def list_baseline_examples(model_key: str) -> list[str]:
213
 
214
 
215
  @lru_cache(maxsize=16)
216
- def _load_models(metric: str, model_key: str, device: str):
217
- record = next((r for r in discover_models_for_metric(metric) if r.model_key == model_key), None)
218
- if record is None:
219
- raise FileNotFoundError(f"Model {model_key!r} not found for metric {metric!r}")
220
-
221
- checkpoint_path = _resolve_checkpoint_path(record.model_path)
222
  sae_cfg = read_sae_config(str(checkpoint_path))
223
 
224
  layer_num = int(sae_cfg.get("layer_num", 3))
@@ -282,10 +261,11 @@ def _codes_for_single_image(
282
  *,
283
  metric: str,
284
  model_key: str,
 
285
  image_path: Path,
286
  ) -> tuple[pd.DataFrame, FeatureMatrix, int]:
287
  device = "cuda" if torch.cuda.is_available() else "cpu"
288
- models = _load_models(metric, model_key, device)
289
 
290
  iqa = models["iqa"]
291
  layer_name = str(models["layer_name"])
@@ -333,6 +313,7 @@ def _resolve_overlay_activations(
333
  ) -> tuple[pd.DataFrame, FeatureMatrix, int]:
334
  metric = str(request.metric).strip().upper()
335
  model_key = str(request.model_key).strip()
 
336
 
337
  meta_df: pd.DataFrame | None = None
338
  features: FeatureMatrix | None = None
@@ -346,7 +327,7 @@ def _resolve_overlay_activations(
346
  if meta_df is None or features is None or crop_size is None:
347
  if upload_image is not None:
348
  device = "cuda" if torch.cuda.is_available() else "cpu"
349
- models = _load_models(metric, model_key, device)
350
  iqa = models["iqa"]
351
  layer_name = str(models["layer_name"])
352
  sae = models["sae"]
@@ -383,6 +364,7 @@ def _resolve_overlay_activations(
383
  meta_df, features, crop_size = _codes_for_single_image(
384
  metric=metric,
385
  model_key=model_key,
 
386
  image_path=image_path,
387
  )
388
  if request.baseline_image_path and image_path is not None and str(image_path).startswith(str(ASSETS_DIR)):
@@ -436,7 +418,7 @@ def render_feature_overlay_png(request: FeatureMapRequest) -> bytes | None:
436
  patches_per_image=None,
437
  crop_size=crop_size,
438
  img_size_inches=4.5,
439
- show_diff=False,
440
  save_dir=None,
441
  show_img=False,
442
  )
 
19
  import analysis.models as _analysis_models
20
  from analysis.viz.vis_heatmaps import visualize_feature_heatmaps, visualize_feature_heatmaps_from_images
21
  from dashboard.image_utils import png_bytes_to_data_url
22
+ from dashboard.model_catalog import get_model_record, require_model_record
23
 
24
 
25
  PROJECT_ROOT = Path(__file__).resolve().parents[1]
 
38
  upload_filename: str | None = None
39
 
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  def _assets_url(path: Path) -> str:
43
  rel = path.relative_to(ASSETS_DIR)
 
195
 
196
 
197
  @lru_cache(maxsize=16)
198
+ def _load_models(metric: str, model_key: str, dataset: str, device: str):
199
+ record = require_model_record(metric, model_key)
200
+ checkpoint_path = Path(record.checkpoint_path)
 
 
 
201
  sae_cfg = read_sae_config(str(checkpoint_path))
202
 
203
  layer_num = int(sae_cfg.get("layer_num", 3))
 
261
  *,
262
  metric: str,
263
  model_key: str,
264
+ dataset: str,
265
  image_path: Path,
266
  ) -> tuple[pd.DataFrame, FeatureMatrix, int]:
267
  device = "cuda" if torch.cuda.is_available() else "cpu"
268
+ models = _load_models(metric, model_key, dataset, device)
269
 
270
  iqa = models["iqa"]
271
  layer_name = str(models["layer_name"])
 
313
  ) -> tuple[pd.DataFrame, FeatureMatrix, int]:
314
  metric = str(request.metric).strip().upper()
315
  model_key = str(request.model_key).strip()
316
+ dataset = str(request.dataset).strip()
317
 
318
  meta_df: pd.DataFrame | None = None
319
  features: FeatureMatrix | None = None
 
327
  if meta_df is None or features is None or crop_size is None:
328
  if upload_image is not None:
329
  device = "cuda" if torch.cuda.is_available() else "cpu"
330
+ models = _load_models(metric, model_key, dataset, device)
331
  iqa = models["iqa"]
332
  layer_name = str(models["layer_name"])
333
  sae = models["sae"]
 
364
  meta_df, features, crop_size = _codes_for_single_image(
365
  metric=metric,
366
  model_key=model_key,
367
+ dataset=dataset,
368
  image_path=image_path,
369
  )
370
  if request.baseline_image_path and image_path is not None and str(image_path).startswith(str(ASSETS_DIR)):
 
418
  patches_per_image=None,
419
  crop_size=crop_size,
420
  img_size_inches=4.5,
421
+ show_mask=False,
422
  save_dir=None,
423
  show_img=False,
424
  )
dashboard/viz_helpers.py CHANGED
@@ -8,8 +8,8 @@ from dash import dcc, html
8
 
9
  from dashboard.model_catalog import (
10
  MODEL_FAMILIES,
11
- discover_models_for_metric,
12
  discover_supported_datasets,
 
13
  load_selector_cache_feature_table,
14
  summarize_selector_cache,
15
  )
@@ -51,17 +51,17 @@ def feature_rows_for_selector(
51
  if not selector_name:
52
  return []
53
 
54
- record = next((r for r in discover_models_for_metric(metric) if r.model_key == model_key), None)
55
  if record is None:
56
  return []
57
 
58
- summaries = summarize_selector_cache(record.model_path, dataset)
59
  summary_by_name = {summary.selector_name: summary for summary in summaries}
60
  selected_summary = summary_by_name.get(selector_name, summaries[0] if summaries else None)
61
  if selected_summary is None:
62
  return []
63
 
64
- feature_table = load_selector_cache_feature_table(selected_summary.cache_path)
65
  if feature_table.empty:
66
  return []
67
 
@@ -86,14 +86,14 @@ def _feature_comparison_rows_cached(
86
  feature_id: int,
87
  active_selector_name: str | None,
88
  ) -> list[dict[str, object]]:
89
- record = next((r for r in discover_models_for_metric(metric) if r.model_key == model_key), None)
90
  if record is None:
91
  return []
92
 
93
- summaries = summarize_selector_cache(record.model_path, dataset)
94
  comparison_rows: list[dict[str, object]] = []
95
  for summary in summaries:
96
- feature_table = load_selector_cache_feature_table(summary.cache_path)
97
  if feature_table.empty:
98
  feature_score = None
99
  feature_rank = None
@@ -242,14 +242,12 @@ def select_feature_id(
242
 
243
  def feature_unavailable_message(
244
  metric: str,
245
- dataset: str,
246
  model_key: str,
247
  feature_id: int,
248
  *,
249
- activation_dataset: str | None = None,
250
  ) -> str | None:
251
- cache_dataset = activation_dataset if activation_dataset is not None else dataset
252
- if feature_in_activation_cache(metric, cache_dataset, model_key, int(feature_id)):
253
  return None
254
  return FILTERED_FEATURE_MESSAGE
255
 
@@ -258,6 +256,60 @@ def feature_empty_state(message: str) -> html.Div:
258
  return html.Div(message, className="feature-empty")
259
 
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  def feature_detail_children(
262
  selector_name: str,
263
  feature_rows: list[dict[str, object]],
 
8
 
9
  from dashboard.model_catalog import (
10
  MODEL_FAMILIES,
 
11
  discover_supported_datasets,
12
+ get_model_record,
13
  load_selector_cache_feature_table,
14
  summarize_selector_cache,
15
  )
 
51
  if not selector_name:
52
  return []
53
 
54
+ record = get_model_record(metric, model_key)
55
  if record is None:
56
  return []
57
 
58
+ summaries = summarize_selector_cache(record.checkpoint_path, dataset)
59
  summary_by_name = {summary.selector_name: summary for summary in summaries}
60
  selected_summary = summary_by_name.get(selector_name, summaries[0] if summaries else None)
61
  if selected_summary is None:
62
  return []
63
 
64
+ feature_table = load_selector_cache_feature_table(str(selected_summary.cache_path))
65
  if feature_table.empty:
66
  return []
67
 
 
86
  feature_id: int,
87
  active_selector_name: str | None,
88
  ) -> list[dict[str, object]]:
89
+ record = get_model_record(metric, model_key)
90
  if record is None:
91
  return []
92
 
93
+ summaries = summarize_selector_cache(record.checkpoint_path, dataset)
94
  comparison_rows: list[dict[str, object]] = []
95
  for summary in summaries:
96
+ feature_table = load_selector_cache_feature_table(str(summary.cache_path))
97
  if feature_table.empty:
98
  feature_score = None
99
  feature_rank = None
 
242
 
243
  def feature_unavailable_message(
244
  metric: str,
 
245
  model_key: str,
246
  feature_id: int,
247
  *,
248
+ activation_dataset: str,
249
  ) -> str | None:
250
+ if feature_in_activation_cache(metric, activation_dataset, model_key, int(feature_id)):
 
251
  return None
252
  return FILTERED_FEATURE_MESSAGE
253
 
 
256
  return html.Div(message, className="feature-empty")
257
 
258
 
259
+ def build_feature_detail_content(
260
+ metric: str,
261
+ dataset: str,
262
+ model_key: str,
263
+ feature_id: int | None,
264
+ selector_name: str | None = None,
265
+ ) -> list[object] | html.Div:
266
+ record = get_model_record(metric, model_key)
267
+ if record is None:
268
+ return html.Div("No selector cache files were found for this model.", className="feature-empty")
269
+
270
+ if not selector_name:
271
+ summaries = summarize_selector_cache(record.checkpoint_path, dataset)
272
+ if not summaries:
273
+ return html.Div("No selector cache files were found for this model.", className="feature-empty")
274
+ selector_name = summaries[0].selector_name
275
+
276
+ rows = feature_rows_for_selector(metric, dataset, model_key, selector_name)
277
+ if not rows:
278
+ return html.Div("No selector cache files were found for this model.", className="feature-empty")
279
+
280
+ selected_ids = feature_ids(rows)
281
+ if not selected_ids:
282
+ return html.Div("No selector cache files were found for this model.", className="feature-empty")
283
+
284
+ try:
285
+ selected_feature_id = int(feature_id)
286
+ except (TypeError, ValueError):
287
+ selected_feature_id = selected_ids[0]
288
+
289
+ unavailable = feature_unavailable_message(
290
+ metric,
291
+ model_key,
292
+ selected_feature_id,
293
+ activation_dataset=UMAP_DATASET,
294
+ )
295
+ if unavailable is not None:
296
+ return feature_empty_state(unavailable)
297
+
298
+ comparison_rows = feature_comparison_rows(
299
+ metric,
300
+ dataset,
301
+ model_key,
302
+ selected_feature_id,
303
+ selector_name,
304
+ )
305
+ return feature_detail_children(
306
+ selector_name or "selector",
307
+ rows,
308
+ selected_feature_id,
309
+ comparison_rows,
310
+ )
311
+
312
+
313
  def feature_detail_children(
314
  selector_name: str,
315
  feature_rows: list[dict[str, object]],
default_configs/06_config.json CHANGED
@@ -4,6 +4,7 @@
4
  "DATASET_ROOT": "/home_2/28m_gov@lab.graphicon.ru/SAE/Kadid",
5
  "CACHE_DIR": "/home_2/28m_gov@lab.graphicon.ru/SAE/xIQA/logs/arniqa_logs/ARNIQA_layer5_lambda5e-4_scale1_exp37/cache",
6
  "IMAGE_LIMIT": 1000,
 
7
  "AGGREGATION_MODES": ["mean", "max", "sum"],
8
  "DEFAULT_AGGREGATION_MODE": "mean",
9
  "UMAP_PARAMS": {
 
4
  "DATASET_ROOT": "/home_2/28m_gov@lab.graphicon.ru/SAE/Kadid",
5
  "CACHE_DIR": "/home_2/28m_gov@lab.graphicon.ru/SAE/xIQA/logs/arniqa_logs/ARNIQA_layer5_lambda5e-4_scale1_exp37/cache",
6
  "IMAGE_LIMIT": 1000,
7
+ "IMAGE_RANDOM_STATE": 42,
8
  "AGGREGATION_MODES": ["mean", "max", "sum"],
9
  "DEFAULT_AGGREGATION_MODE": "mean",
10
  "UMAP_PARAMS": {
default_configs/sae_vis_config.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
- "SAE_CHECKPOINT_PATH": "/data/logs/ARNIQA_layer3_lambda1e-5_scale1_exp2/checkpoints/checkpoint-200200",
3
- "DATASETS_ROOT": "/data/Kadid",
4
- "CACHE_DIR": "/data/logs/ARNIQA_layer3_lambda1e-5_scale1_exp2/cache",
5
  "DATASET": "kadid10k",
6
  "CROP_SIZE": 224,
7
  "DOWNSCALE_FACTOR": 2,
@@ -9,10 +9,6 @@
9
  "NUM_WORKERS": 4,
10
  "DEVICE": "cuda",
11
  "ACTIVATION_STEPS_TO_KEEP": [],
12
- "SUPPORTED_DATASETS": [
13
- "kadid10k",
14
- "local_kadid"
15
- ],
16
  "CORR_TOP_K": 30,
17
  "HEATMAP_CRITERION": "max",
18
  "N_TOP_FEATURES_HEATMAPS": 3,
 
1
  {
2
+ "SAE_CHECKPOINT_PATH": "/home/28m_gov@lab.graphicon.ru/SAE/xIQA/logs/ARNIQA_layer3_lambda1e-5_scale1_exp2/checkpoint-200200",
3
+ "DATASETS_ROOT": "/home/28m_gov@lab.graphicon.ru/SAE/Kadid",
4
+ "CACHE_DIR": "/home/28m_gov@lab.graphicon.ru/SAE/xIQA/logs/ARNIQA_layer3_lambda1e-5_scale1_exp2/checkpoint-200200/cache",
5
  "DATASET": "kadid10k",
6
  "CROP_SIZE": 224,
7
  "DOWNSCALE_FACTOR": 2,
 
9
  "NUM_WORKERS": 4,
10
  "DEVICE": "cuda",
11
  "ACTIVATION_STEPS_TO_KEEP": [],
 
 
 
 
12
  "CORR_TOP_K": 30,
13
  "HEATMAP_CRITERION": "max",
14
  "N_TOP_FEATURES_HEATMAPS": 3,
pages/feature_maps.py CHANGED
@@ -12,7 +12,7 @@ from dashboard.user_image_service import (
12
  list_baseline_example_records,
13
  render_feature_overlay,
14
  )
15
- from dashboard.viz_helpers import visualization_params
16
 
17
 
18
  ROOT = Path(__file__).resolve().parents[1]
@@ -103,6 +103,25 @@ layout = html.Div(
103
  debounce=True,
104
  className="feature-jump-input",
105
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  html.Div(
107
  id="fm-status",
108
  className="home-status",
@@ -290,3 +309,28 @@ def update_overlay(
290
  return "", "Overlay unavailable."
291
  return overlay_url, ""
292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  list_baseline_example_records,
13
  render_feature_overlay,
14
  )
15
+ from dashboard.viz_helpers import build_feature_detail_content, visualization_params
16
 
17
 
18
  ROOT = Path(__file__).resolve().parents[1]
 
103
  debounce=True,
104
  className="feature-jump-input",
105
  ),
106
+ html.Div(
107
+ dcc.Loading(
108
+ id="fm-feature-detail-loading",
109
+ type="default",
110
+ color="#2563eb",
111
+ className="loading-block feature-detail-body-loading",
112
+ children=html.Div(
113
+ id="fm-feature-detail-body",
114
+ className="feature-detail-card",
115
+ children=[
116
+ html.Div(
117
+ "Enter a feature id to see statistics.",
118
+ className="feature-empty",
119
+ ),
120
+ ],
121
+ ),
122
+ ),
123
+ style={"marginTop": "16px"},
124
+ ),
125
  html.Div(
126
  id="fm-status",
127
  className="home-status",
 
309
  return "", "Overlay unavailable."
310
  return overlay_url, ""
311
 
312
+
313
+ @callback(
314
+ Output("fm-feature-detail-body", "children"),
315
+ Input("_pages_location", "search"),
316
+ Input("fm-feature-id", "value"),
317
+ )
318
+ def render_fm_feature_detail(search: str | None, feature_id_value):
319
+ params = visualization_params(search)
320
+ if params is None:
321
+ return html.Div("Missing page parameters.", className="feature-empty")
322
+
323
+ metric, dataset, model_key = params
324
+ if not model_key:
325
+ return html.Div("Select a model on the Home page first.", className="feature-empty")
326
+
327
+ try:
328
+ feature_id = int(feature_id_value) if feature_id_value is not None else None
329
+ except (TypeError, ValueError):
330
+ return html.Div("Enter a valid feature id.", className="feature-empty")
331
+
332
+ if feature_id is None:
333
+ return html.Div("Enter a feature id to see statistics.", className="feature-empty")
334
+
335
+ return build_feature_detail_content(metric, dataset, model_key, feature_id)
336
+
pages/visualizations.py CHANGED
@@ -9,7 +9,7 @@ from dash import Input, Output, State, callback, clientside_callback, ctx, dcc,
9
  from dashboard.image_utils import get_top_feature_overlays, overlay_artifact_data_url
10
  from dashboard.layout import page_back_nav
11
  from dashboard.model_catalog import (
12
- discover_models_for_metric,
13
  summarize_model_record,
14
  summarize_selector_cache,
15
  )
@@ -31,10 +31,9 @@ from dashboard.umap_service import (
31
  format_umap_status,
32
  )
33
  from dashboard.viz_helpers import (
 
34
  cached_hover_meta_line,
35
  cached_hover_thumb,
36
- feature_comparison_rows,
37
- feature_detail_children,
38
  feature_empty_state,
39
  feature_ids,
40
  feature_rows_for_selector,
@@ -506,9 +505,9 @@ layout = html.Div(
506
  ),
507
  dcc.Input(
508
  id="feature-jump-input",
509
- type="number",
510
- min=0,
511
- step=1,
512
  debounce=True,
513
  placeholder="Jump to feature id",
514
  className="feature-jump-input",
@@ -607,7 +606,7 @@ def render_model_info(search: str | None):
607
  return html.Div("Missing visualization parameters.", className="feature-empty")
608
 
609
  metric, selection_dataset, model_key = params
610
- record = next((r for r in discover_models_for_metric(metric) if r.model_key == model_key), None)
611
 
612
  children = [
613
  html.Div([html.Span("Metric: "), html.Strong(metric)]),
@@ -624,7 +623,7 @@ def render_model_info(search: str | None):
624
  try:
625
  from dashboard.model_catalog import summarize_feature_filter_cache
626
 
627
- filter_lines = summarize_feature_filter_cache(record.model_path, selection_dataset)
628
  except Exception:
629
  filter_lines = ["Feature filter summary unavailable"]
630
 
@@ -653,11 +652,11 @@ def init_feature_selector(search: str | None):
653
  return [], None, True
654
 
655
  metric, selection_dataset, model_key = params
656
- record = next((r for r in discover_models_for_metric(metric) if r.model_key == model_key), None)
657
  if record is None:
658
  return [], None, True
659
 
660
- summaries = summarize_selector_cache(record.model_path, selection_dataset)
661
  if not summaries:
662
  return [], None, True
663
 
@@ -918,10 +917,9 @@ def display_umap_hover_content(hover_data, search: str | None, umap_mode: str |
918
 
919
  params = visualization_params(search)
920
  if params is not None:
921
- metric, selection_dataset, model_key = params
922
  unavailable = feature_unavailable_message(
923
  metric,
924
- selection_dataset,
925
  model_key,
926
  fid,
927
  activation_dataset=UMAP_DATASET,
@@ -1053,13 +1051,11 @@ def update_selected_feature(
1053
  requested_feature_id=requested_feature_id,
1054
  )
1055
  if selected_feature_id is not None:
1056
- activation_dataset = UMAP_DATASET if trigger_id == "umap-graph" else None
1057
  unavailable = feature_unavailable_message(
1058
  metric,
1059
- selection_dataset,
1060
  model_key,
1061
  int(selected_feature_id),
1062
- activation_dataset=activation_dataset,
1063
  )
1064
  if unavailable is not None:
1065
  status = unavailable
@@ -1082,36 +1078,13 @@ def render_feature_detail(
1082
  return html.Div("No selector cache files were found for this model.", className="feature-empty")
1083
 
1084
  metric, selection_dataset, model_key = params
1085
- rows = feature_rows_for_selector(metric, selection_dataset, model_key, selector_name)
1086
- if not rows:
1087
- return html.Div("No selector cache files were found for this model.", className="feature-empty")
1088
-
1089
- selected_ids = feature_ids(rows)
1090
- if not selected_ids:
1091
- return html.Div("No selector cache files were found for this model.", className="feature-empty")
1092
-
1093
- try:
1094
- selected_feature_id = int(selected_feature_id)
1095
- except (TypeError, ValueError):
1096
- selected_feature_id = selected_ids[0]
1097
-
1098
- unavailable = feature_unavailable_message(metric, selection_dataset, model_key, selected_feature_id)
1099
- if unavailable is not None:
1100
- return feature_empty_state(unavailable)
1101
-
1102
- comparison_rows = feature_comparison_rows(
1103
  metric,
1104
  selection_dataset,
1105
  model_key,
1106
  selected_feature_id,
1107
  selector_name,
1108
  )
1109
- return feature_detail_children(
1110
- selector_name or "selector",
1111
- rows,
1112
- selected_feature_id,
1113
- comparison_rows,
1114
- )
1115
 
1116
 
1117
  @callback(
@@ -1137,7 +1110,12 @@ def render_feature_top_images(
1137
  except Exception:
1138
  return _empty_feature_image_outputs("No feature selected.")
1139
 
1140
- unavailable = feature_unavailable_message(metric, selection_dataset, model_key, feature_id)
 
 
 
 
 
1141
  if unavailable is not None:
1142
  return _empty_feature_image_outputs(unavailable)
1143
 
 
9
  from dashboard.image_utils import get_top_feature_overlays, overlay_artifact_data_url
10
  from dashboard.layout import page_back_nav
11
  from dashboard.model_catalog import (
12
+ get_model_record,
13
  summarize_model_record,
14
  summarize_selector_cache,
15
  )
 
31
  format_umap_status,
32
  )
33
  from dashboard.viz_helpers import (
34
+ build_feature_detail_content,
35
  cached_hover_meta_line,
36
  cached_hover_thumb,
 
 
37
  feature_empty_state,
38
  feature_ids,
39
  feature_rows_for_selector,
 
505
  ),
506
  dcc.Input(
507
  id="feature-jump-input",
508
+ type="text",
509
+ inputMode="numeric",
510
+ pattern="[0-9]*",
511
  debounce=True,
512
  placeholder="Jump to feature id",
513
  className="feature-jump-input",
 
606
  return html.Div("Missing visualization parameters.", className="feature-empty")
607
 
608
  metric, selection_dataset, model_key = params
609
+ record = get_model_record(metric, model_key)
610
 
611
  children = [
612
  html.Div([html.Span("Metric: "), html.Strong(metric)]),
 
623
  try:
624
  from dashboard.model_catalog import summarize_feature_filter_cache
625
 
626
+ filter_lines = summarize_feature_filter_cache(record.checkpoint_path, selection_dataset)
627
  except Exception:
628
  filter_lines = ["Feature filter summary unavailable"]
629
 
 
652
  return [], None, True
653
 
654
  metric, selection_dataset, model_key = params
655
+ record = get_model_record(metric, model_key)
656
  if record is None:
657
  return [], None, True
658
 
659
+ summaries = summarize_selector_cache(record.checkpoint_path, selection_dataset)
660
  if not summaries:
661
  return [], None, True
662
 
 
917
 
918
  params = visualization_params(search)
919
  if params is not None:
920
+ metric, _selection_dataset, model_key = params
921
  unavailable = feature_unavailable_message(
922
  metric,
 
923
  model_key,
924
  fid,
925
  activation_dataset=UMAP_DATASET,
 
1051
  requested_feature_id=requested_feature_id,
1052
  )
1053
  if selected_feature_id is not None:
 
1054
  unavailable = feature_unavailable_message(
1055
  metric,
 
1056
  model_key,
1057
  int(selected_feature_id),
1058
+ activation_dataset=UMAP_DATASET,
1059
  )
1060
  if unavailable is not None:
1061
  status = unavailable
 
1078
  return html.Div("No selector cache files were found for this model.", className="feature-empty")
1079
 
1080
  metric, selection_dataset, model_key = params
1081
+ return build_feature_detail_content(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1082
  metric,
1083
  selection_dataset,
1084
  model_key,
1085
  selected_feature_id,
1086
  selector_name,
1087
  )
 
 
 
 
 
 
1088
 
1089
 
1090
  @callback(
 
1110
  except Exception:
1111
  return _empty_feature_image_outputs("No feature selected.")
1112
 
1113
+ unavailable = feature_unavailable_message(
1114
+ metric,
1115
+ model_key,
1116
+ feature_id,
1117
+ activation_dataset=UMAP_DATASET,
1118
+ )
1119
  if unavailable is not None:
1120
  return _empty_feature_image_outputs(unavailable)
1121
 
train_code/benjdataset.py CHANGED
@@ -8,14 +8,14 @@ import torch
8
  from torch.utils.data import Dataset, DataLoader
9
  from torchvision import transforms # type:ignore
10
 
11
- # искажения из ARNIQA для аугментации
12
  from train_code.aug_utils.utils_data import distort_images
13
  from utils.utils_data import resize_crop
14
 
15
 
16
  def collate_fn(data):
17
- # Надо подумать насчёт того, правильно ли было выносить ресайз кроп в гетайтем NOTE
18
- # Но у них коде вроде так же
19
  # images = torch.stack([resize_crop(example['image'], crop_size=CROP_SIZE,
20
  # downscale_factor=DOWNSCALE_FACTOR) for example in data])
21
  # print(data.keys())
@@ -61,8 +61,8 @@ class DeepFeaturesDataset(Dataset):
61
  raw_image = resize_crop(raw_image, crop_size=self.crop_size, downscale_factor=self.downscale_factor)
62
  if random.random() > self.prestine_prob:
63
  try:
64
- # Для того, чтобы при обучении SAE метрика видела искажённые изображения, использую пайплайн искажений из статьи ARNIQA.
65
- # Код в aug_utils взят из репо ARNIQA
66
  raw_image = distort_images(raw_image.clone())[0]
67
  except Exception as e:
68
  print(f'[Warning] Degradation operation failed due to {e}!')
@@ -82,8 +82,8 @@ def create_dataloaders(dataset_name: str,
82
  token: str,
83
  transform: Optional[Callable] = None) -> Tuple[DataLoader, DataLoader]:
84
  # ds = load_dataset("timm/imagenet-1k-wds", split='train', cache_dir='D:/adapter_experiment/hf_cache', data_files='**/*-validation-*.tar')
85
- # тут грузится большой датасет (несколько тб), можно заменить на любой другой источник данных. Закомментил, чтобы нечаянно не начать их грузить
86
- # Используем небольшой датасет для тестирования
87
 
88
  ds = load_dataset(dataset_name, split=split, streaming=True, cache_dir='./datasets/')
89
  # ds_data = datasets.Dataset.from_list(ds_head)
 
8
  from torch.utils.data import Dataset, DataLoader
9
  from torchvision import transforms # type:ignore
10
 
11
+ # ARNIQA distortions for augmentation
12
  from train_code.aug_utils.utils_data import distort_images
13
  from utils.utils_data import resize_crop
14
 
15
 
16
  def collate_fn(data):
17
+ # TODO: reconsider whether resize/crop should live in __getitem__ instead
18
+ # Their code appears to do it the same way
19
  # images = torch.stack([resize_crop(example['image'], crop_size=CROP_SIZE,
20
  # downscale_factor=DOWNSCALE_FACTOR) for example in data])
21
  # print(data.keys())
 
61
  raw_image = resize_crop(raw_image, crop_size=self.crop_size, downscale_factor=self.downscale_factor)
62
  if random.random() > self.prestine_prob:
63
  try:
64
+ # During SAE training, the metric should see distorted images; use the ARNIQA paper distortion pipeline.
65
+ # aug_utils code is taken from the ARNIQA repo
66
  raw_image = distort_images(raw_image.clone())[0]
67
  except Exception as e:
68
  print(f'[Warning] Degradation operation failed due to {e}!')
 
82
  token: str,
83
  transform: Optional[Callable] = None) -> Tuple[DataLoader, DataLoader]:
84
  # ds = load_dataset("timm/imagenet-1k-wds", split='train', cache_dir='D:/adapter_experiment/hf_cache', data_files='**/*-validation-*.tar')
85
+ # The full dataset is huge (several TB); swap in any other data source. Commented out to avoid loading it by accident.
86
+ # Use a small dataset for testing
87
 
88
  ds = load_dataset(dataset_name, split=split, streaming=True, cache_dir='./datasets/')
89
  # ds_data = datasets.Dataset.from_list(ds_head)
train_code/timmdataset.py CHANGED
@@ -6,13 +6,13 @@ import torch
6
  from torch.utils.data import Dataset, DataLoader
7
  from torchvision import transforms # type:ignore
8
 
9
- # искажения из ARNIQA для аугментации
10
  from train_code.aug_utils.utils_data import distort_images
11
  from utils.utils_data import resize_crop
12
 
13
  def collate_fn(data):
14
- # Надо подумать насчёт того, правильно ли было выносить ресайз кроп в гетайтем NOTE
15
- # Но у них коде вроде так же
16
  # images = torch.stack([resize_crop(example['image'], crop_size=CROP_SIZE,
17
  # downscale_factor=DOWNSCALE_FACTOR) for example in data])
18
  # print(data.keys())
@@ -58,8 +58,8 @@ class DeepFeaturesDataset(Dataset):
58
  raw_image = resize_crop(raw_image, crop_size=self.crop_size, downscale_factor=self.downscale_factor)
59
  if random.random() > self.prestine_prob:
60
  try:
61
- # Для того, чтобы при обучении SAE метрика видела искажённые изображения, использую пайплайн искажений из статьи ARNIQA.
62
- # Код в aug_utils взят из репо ARNIQA
63
  raw_image = distort_images(raw_image.clone())[0]
64
  except Exception as e:
65
  print(f'[Warning] Degradation operation failed due to {e}!')
@@ -79,15 +79,15 @@ def create_dataloaders(dataset_name: str,
79
  token: str,
80
  transform: Optional[Callable] = None) -> Tuple[DataLoader, DataLoader]:
81
  # ds = load_dataset("timm/imagenet-1k-wds", split='train', cache_dir='D:/adapter_experiment/hf_cache', data_files='**/*-validation-*.tar')
82
- # тут грузится большой датасет (несколько тб), можно заменить на любой другой источник данных. Закомментил, чтобы нечаянно не начать их грузить
83
- # Используем небольшой датасет для тестирования
84
 
85
  # ds = load_dataset(dataset_name, split=split, streaming=True, cache_dir='./datasets/')
86
  # ds_head = list(ds.take(10))
87
  # ds_data = datasets.Dataset.from_list(ds_head)
88
  # dset = DeepFeaturesDataset(ds_data, prestine_prob = prestine_prob)
89
 
90
- # загружаем только первые 1000 изображений для тестирования
91
  ds = load_dataset(dataset_name, split=split, cache_dir='/mgov/datasets/', token=token)
92
  # ds = ds.select(range(10000)) # !!!!!!!!!!!
93
  dset = DeepFeaturesDataset(
 
6
  from torch.utils.data import Dataset, DataLoader
7
  from torchvision import transforms # type:ignore
8
 
9
+ # ARNIQA distortions for augmentation
10
  from train_code.aug_utils.utils_data import distort_images
11
  from utils.utils_data import resize_crop
12
 
13
  def collate_fn(data):
14
+ # TODO: reconsider whether resize/crop should live in __getitem__ instead
15
+ # Their code appears to do it the same way
16
  # images = torch.stack([resize_crop(example['image'], crop_size=CROP_SIZE,
17
  # downscale_factor=DOWNSCALE_FACTOR) for example in data])
18
  # print(data.keys())
 
58
  raw_image = resize_crop(raw_image, crop_size=self.crop_size, downscale_factor=self.downscale_factor)
59
  if random.random() > self.prestine_prob:
60
  try:
61
+ # During SAE training, the metric should see distorted images; use the ARNIQA paper distortion pipeline.
62
+ # aug_utils code is taken from the ARNIQA repo
63
  raw_image = distort_images(raw_image.clone())[0]
64
  except Exception as e:
65
  print(f'[Warning] Degradation operation failed due to {e}!')
 
79
  token: str,
80
  transform: Optional[Callable] = None) -> Tuple[DataLoader, DataLoader]:
81
  # ds = load_dataset("timm/imagenet-1k-wds", split='train', cache_dir='D:/adapter_experiment/hf_cache', data_files='**/*-validation-*.tar')
82
+ # The full dataset is huge (several TB); swap in any other data source. Commented out to avoid loading it by accident.
83
+ # Use a small dataset for testing
84
 
85
  # ds = load_dataset(dataset_name, split=split, streaming=True, cache_dir='./datasets/')
86
  # ds_head = list(ds.take(10))
87
  # ds_data = datasets.Dataset.from_list(ds_head)
88
  # dset = DeepFeaturesDataset(ds_data, prestine_prob = prestine_prob)
89
 
90
+ # Load only the first 1000 images for testing
91
  ds = load_dataset(dataset_name, split=split, cache_dir='/mgov/datasets/', token=token)
92
  # ds = ds.select(range(10000)) # !!!!!!!!!!!
93
  dset = DeepFeaturesDataset(
train_code/train_script_arniqa.py CHANGED
@@ -78,7 +78,7 @@ def parse_args():
78
  help="Initialization of SAE weights with fixed norm.",
79
  )
80
  # parser.add_argument(
81
- # "--sae_input_dim", # Будет определяться автоматически
82
  # type=int,
83
  # default=256,
84
  # help="Dimentionality of activations.",
@@ -311,8 +311,8 @@ def get_layer_output_size(model: InferenceModel, device: Union[str, torch.device
311
 
312
  def set_hook(model: InferenceModel, layer_num: int, layer_name: str) -> Tuple[torch.utils.hooks.RemovableHandle, dict]:
313
 
314
- # Навешивание хука на нужный слой модели IQA для получения активаций
315
- # подробнее про хуки: https://web.stanford.edu/~nanbhas/blog/forward-hooks-pytorch/
316
  activations = {}
317
 
318
  def getActivation(name):
@@ -331,7 +331,7 @@ def create_model(device: Union[str, torch.device], iqa_weight_dtype: torch.dtype
331
  return model
332
 
333
  # create_sae, setup_lambda_scaling, setup_optimizer_and_scheduler, extract_activations,
334
- # compute_lambda, train_step, log_train_metrics, save_checkpoint импортируются из train_utils.py
335
 
336
 
337
  def save_sae_config(
@@ -340,19 +340,19 @@ def save_sae_config(
340
  inner_dim: int,
341
  output_dir: Optional[str] = None,
342
  ) -> None:
343
- """Сохраняет гиперпараметры SAE в JSON-файл рядом с чекпоинтами.
344
- Формат достаточен для воспроизведения архитектуры модели без дополнительных аргументов.
345
  """
346
  cfg = {
347
  "iqa_metric": "arniqa-kadid",
348
  "layer_num": args.layer_num,
349
  "scaling_factor": args.scaling_factor,
350
- # архитектура
351
  "sae_type": args.sae_type,
352
  "sae_input_dim": sae_input_dim,
353
  "inner_dim": inner_dim,
354
  "weight_norm_init": args.weight_norm_init,
355
- # MatchingPursuitSAE-специфичные параметры
356
  "mp_threshold": args.mp_threshold,
357
  "mp_normalize": bool(args.mp_normalize),
358
  }
@@ -364,7 +364,7 @@ def save_sae_config(
364
  print(f"SAE config saved to {path}")
365
 
366
 
367
- # validate_epoch, train_epoch, fix_seed импортируются из train_utils.py
368
 
369
 
370
  def print_hyperparams(
 
78
  help="Initialization of SAE weights with fixed norm.",
79
  )
80
  # parser.add_argument(
81
+ # "--sae_input_dim", # Determined automatically
82
  # type=int,
83
  # default=256,
84
  # help="Dimentionality of activations.",
 
311
 
312
  def set_hook(model: InferenceModel, layer_num: int, layer_name: str) -> Tuple[torch.utils.hooks.RemovableHandle, dict]:
313
 
314
+ # Register a hook on the target IQA layer to capture activations
315
+ # More on hooks: https://web.stanford.edu/~nanbhas/blog/forward-hooks-pytorch/
316
  activations = {}
317
 
318
  def getActivation(name):
 
331
  return model
332
 
333
  # create_sae, setup_lambda_scaling, setup_optimizer_and_scheduler, extract_activations,
334
+ # compute_lambda, train_step, log_train_metrics, save_checkpoint are imported from train_utils.py
335
 
336
 
337
  def save_sae_config(
 
340
  inner_dim: int,
341
  output_dir: Optional[str] = None,
342
  ) -> None:
343
+ """Save SAE hyperparameters to a JSON file next to checkpoints.
344
+ The format is sufficient to reproduce the model architecture without extra arguments.
345
  """
346
  cfg = {
347
  "iqa_metric": "arniqa-kadid",
348
  "layer_num": args.layer_num,
349
  "scaling_factor": args.scaling_factor,
350
+ # architecture
351
  "sae_type": args.sae_type,
352
  "sae_input_dim": sae_input_dim,
353
  "inner_dim": inner_dim,
354
  "weight_norm_init": args.weight_norm_init,
355
+ # MatchingPursuitSAE-specific parameters
356
  "mp_threshold": args.mp_threshold,
357
  "mp_normalize": bool(args.mp_normalize),
358
  }
 
364
  print(f"SAE config saved to {path}")
365
 
366
 
367
+ # validate_epoch, train_epoch, fix_seed are imported from train_utils.py
368
 
369
 
370
  def print_hyperparams(
train_code/train_script_liqe.py CHANGED
@@ -361,7 +361,7 @@ def create_model(
361
 
362
 
363
  # create_sae, setup_lambda_scaling, setup_optimizer_and_scheduler, extract_activations,
364
- # compute_lambda, train_step, log_train_metrics, save_checkpoint импортируются из train_utils.py
365
 
366
 
367
  def save_sae_config(
 
361
 
362
 
363
  # create_sae, setup_lambda_scaling, setup_optimizer_and_scheduler, extract_activations,
364
+ # compute_lambda, train_step, log_train_metrics, save_checkpoint are imported from train_utils.py
365
 
366
 
367
  def save_sae_config(
train_code/train_script_maniqa.py CHANGED
@@ -75,12 +75,12 @@ def get_layer_output_size(
75
 
76
 
77
  def set_hook(model, layer_name: str, swin_num: int = 2, layer_num: int = 1) -> Tuple[Any, dict]:
78
- """Навешивает hook на выбранный BasicLayer SwinTransformer модели MANIQA.
79
 
80
- swin_num: 1 (swintransformer1, dim=768) или 2 (swintransformer2, dim=384).
81
- layer_num: индекс BasicLayer внутри выбранного SwinTransformer (0 или 1).
82
- Выход слоя: (B, L, C) последовательность токенов.
83
- Hook флэттит до (B*L, C), чтобы SAE мог работать с отдельными токенами.
84
  """
85
  activations: dict = {}
86
 
@@ -99,7 +99,7 @@ def save_sae_config(
99
  inner_dim: int,
100
  output_dir: Optional[str] = None,
101
  ) -> None:
102
- """Сохраняет гиперпараметры SAE в JSON-файл рядом с чекпойнтами."""
103
  cfg = {
104
  "iqa_metric": "maniqa",
105
  "swin_num": args.swin_num,
@@ -494,7 +494,7 @@ def main():
494
  save_sae_config(args, sae_input_dim, inner_dim)
495
 
496
  dataloader_train, dataloader_test = create_dataloaders(
497
- DATASET_NAME, 'train', 0, args.crop_size, 1, args.train_frac, bs, num_workers, collate_fn, token=args.hf_token) # Downscale=1, потому что так в оригинале, но возможно стоит добавить.
498
 
499
  autoenc_model = create_sae(
500
  sae_input_dim, inner_dim, weight_norm_init, device, weight_dtype,
@@ -503,7 +503,7 @@ def main():
503
  mp_normalize=bool(args.mp_normalize),
504
  )
505
 
506
- # Выбираем функцию потерь в зависимости от типа SAE
507
  _loss_base = get_loss_fn(args.sae_type)
508
  _aux_alpha = args.aux_alpha
509
 
 
75
 
76
 
77
  def set_hook(model, layer_name: str, swin_num: int = 2, layer_num: int = 1) -> Tuple[Any, dict]:
78
+ """Register a hook on the selected MANIQA SwinTransformer BasicLayer.
79
 
80
+ swin_num: 1 (swintransformer1, dim=768) or 2 (swintransformer2, dim=384).
81
+ layer_num: BasicLayer index inside the selected SwinTransformer (0 or 1).
82
+ Layer output: (B, L, C) token sequence.
83
+ The hook flattens to (B*L, C) so the SAE can process individual tokens.
84
  """
85
  activations: dict = {}
86
 
 
99
  inner_dim: int,
100
  output_dir: Optional[str] = None,
101
  ) -> None:
102
+ """Save SAE hyperparameters to a JSON file next to checkpoints."""
103
  cfg = {
104
  "iqa_metric": "maniqa",
105
  "swin_num": args.swin_num,
 
494
  save_sae_config(args, sae_input_dim, inner_dim)
495
 
496
  dataloader_train, dataloader_test = create_dataloaders(
497
+ DATASET_NAME, 'train', 0, args.crop_size, 1, args.train_frac, bs, num_workers, collate_fn, token=args.hf_token) # Downscale=1 as in the original; may be worth making configurable.
498
 
499
  autoenc_model = create_sae(
500
  sae_input_dim, inner_dim, weight_norm_init, device, weight_dtype,
 
503
  mp_normalize=bool(args.mp_normalize),
504
  )
505
 
506
+ # Choose the loss function based on SAE type
507
  _loss_base = get_loss_fn(args.sae_type)
508
  _aux_alpha = args.aux_alpha
509
 
train_code/train_utils.py CHANGED
@@ -1,4 +1,4 @@
1
- """Общие утилиты для обучения SAE.
2
  """
3
 
4
  import os
@@ -52,7 +52,7 @@ def fix_seed(seed: int) -> None:
52
  torch.backends.cudnn.deterministic = True
53
  torch.backends.cudnn.benchmark = False
54
 
55
- # Чекпоинты
56
 
57
  def _extract_checkpoint_step(name: str) -> Optional[int]:
58
  prefix = "checkpoint-"
@@ -117,7 +117,7 @@ def create_sae(
117
  autoenc_model.train()
118
  return autoenc_model
119
 
120
- # Оптимизаторы и scheduler'ы
121
 
122
  def create_optimizer(
123
  model: torch.nn.Module,
@@ -203,7 +203,7 @@ def compute_lambda(
203
  def calc_loss_reconstr(x: torch.Tensor, x_rec: torch.Tensor) -> torch.Tensor:
204
  return torch.nn.functional.mse_loss(x_rec, x)
205
 
206
- # Извлечение активаций
207
 
208
  def extract_activations(
209
  model: torch.nn.Module,
@@ -222,7 +222,7 @@ def extract_activations(
222
  data *= scaling_factor
223
  return data
224
 
225
- # Обучение
226
 
227
  def train_step(
228
  autoenc_model: torch.nn.Module,
@@ -240,7 +240,7 @@ def train_step(
240
  real_batch_size = data.shape[0]
241
  optimizer.zero_grad()
242
 
243
- # Перемешиваем батч и семплируем sample_frac долю пространственных позиций
244
  perm = torch.randperm(real_batch_size, device=data.device)
245
  data = data[perm[: int(len(perm) * sample_frac)]]
246
  chunk_size = max(1, real_batch_size // grad_accum_steps)
 
1
+ """Shared utilities for SAE training.
2
  """
3
 
4
  import os
 
52
  torch.backends.cudnn.deterministic = True
53
  torch.backends.cudnn.benchmark = False
54
 
55
+ # Checkpoints
56
 
57
  def _extract_checkpoint_step(name: str) -> Optional[int]:
58
  prefix = "checkpoint-"
 
117
  autoenc_model.train()
118
  return autoenc_model
119
 
120
+ # Optimizers and schedulers
121
 
122
  def create_optimizer(
123
  model: torch.nn.Module,
 
203
  def calc_loss_reconstr(x: torch.Tensor, x_rec: torch.Tensor) -> torch.Tensor:
204
  return torch.nn.functional.mse_loss(x_rec, x)
205
 
206
+ # Activation extraction
207
 
208
  def extract_activations(
209
  model: torch.nn.Module,
 
222
  data *= scaling_factor
223
  return data
224
 
225
+ # Training
226
 
227
  def train_step(
228
  autoenc_model: torch.nn.Module,
 
240
  real_batch_size = data.shape[0]
241
  optimizer.zero_grad()
242
 
243
+ # Shuffle the batch and sample sample_frac of spatial positions
244
  perm = torch.randperm(real_batch_size, device=data.device)
245
  data = data[perm[: int(len(perm) * sample_frac)]]
246
  chunk_size = max(1, real_batch_size // grad_accum_steps)